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/// Progress from a retry inside one inference request.
184///
185/// The observer surface deliberately carries a generic failure class rather
186/// than a provider response body: progress streams are operational metadata,
187/// not a second channel for arbitrary upstream content.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct InferenceRetryProgress {
190    /// Canonical model id whose remote request is being retried.
191    pub model: String,
192    /// The attempt about to start (the initial request is attempt 1).
193    pub attempt: u32,
194    /// Stable generic failure class such as `http_status` or `transport`.
195    pub reason: &'static str,
196    /// Delay before the named attempt starts.
197    pub backoff_ms: u64,
198}
199
200#[derive(Error, Debug)]
201pub enum InferenceError {
202    #[error("model not found: {0}")]
203    ModelNotFound(String),
204
205    /// Adaptive routing could not honor a caller-required model separation
206    /// boundary. Raised before dispatch, so no excluded backend serves even a
207    /// failed attempt.
208    #[error("no eligible model remains after required exclusions: {excluded_models}")]
209    NoEligibleModel { excluded_models: String },
210
211    #[error("model download failed: {0}")]
212    DownloadFailed(String),
213
214    #[error("inference failed: {0}")]
215    InferenceFailed(String),
216
217    /// The caller bound inference to a catalog row/revision that no longer
218    /// matches the daemon's request snapshot. This is an optimistic
219    /// concurrency rejection, not a provider failure; it must fail before any
220    /// dispatch and retain a typed wire mapping at the daemon boundary.
221    #[error("catalog precondition mismatch: {detail}")]
222    CatalogPreconditionMismatch { detail: String },
223
224    /// Exact isolated-worker kill + wait confirmed termination for this
225    /// request. The server-owned registry decides whether the outward terminal
226    /// is cancel or deadline; the inference engine uses this sentinel only to
227    /// stop retries/fallbacks without penalizing model health.
228    #[error("controlled inference termination confirmed")]
229    ControlledTermination,
230
231    #[error(transparent)]
232    ModelManagement(#[from] model_management::ModelManagementError),
233
234    /// A CAR-managed local model could not be admitted without violating the
235    /// user's saved allocation or the machine's live emergency reserve.
236    #[error("{recovery}")]
237    LocalResourceBlocked {
238        preflight: resource_policy::LocalLoadPreflight,
239        recovery: String,
240    },
241
242    /// A remote call that failed on a *retryable* class — 5xx / 429 / 529 /
243    /// timeout / connection reset — after the bounded retry budget was
244    /// exhausted. Distinct from [`InferenceError::InferenceFailed`] so a
245    /// caller can tell "infra blip, safe to re-run" from "the request itself
246    /// is wrong" (4xx / auth / validation). `status` carries the final HTTP
247    /// status when the failure was an HTTP response; `None` for a transport
248    /// or timeout error. Used by `car run-task` to classify a run as
249    /// `infra_inference` (re-run) vs a non-retryable failure (alert).
250    #[error("transient remote failure after retries (status={status:?}): {message}")]
251    Transient {
252        status: Option<u16>,
253        message: String,
254    },
255
256    /// The CALLER'S armed deadline (`infer.deadline`) elapsed while the remote
257    /// request was still in flight or before a retry could fit inside what
258    /// remained. Distinct from [`InferenceError::Transient`] — this is not an
259    /// infra blip and re-running with the same deadline hits the same wall;
260    /// the caller asked for exactly this bound and the error names it so the
261    /// termination is attributable to the deadline that was applied (car-eyj:
262    /// the old shape reported -32603 transient at a ceiling no config exposed).
263    #[error("deadline exceeded: the caller's {applied_ms} ms infer deadline elapsed after {elapsed_ms} ms; last attempt: {last_error}")]
264    DeadlineExceeded {
265        applied_ms: u64,
266        elapsed_ms: u64,
267        last_error: String,
268    },
269
270    /// A request mode is accepted on the public surface but the
271    /// selected backend hasn't wired it yet. Distinct from
272    /// `InferenceFailed` so callers can distinguish "backend can't"
273    /// from "backend tried and something went wrong".
274    #[error("mode {mode} not implemented on backend {backend}: {reason}")]
275    UnsupportedMode {
276        mode: &'static str,
277        backend: &'static str,
278        reason: &'static str,
279    },
280
281    /// The provider **account** rejected the call — key absent or rejected
282    /// (401/403), or out of credits/quota (402).
283    ///
284    /// Account-wide, so it says nothing about the model that happened to be
285    /// selected. Booking it as a model failure benches healthy models over a
286    /// billing problem, and — because the health EMA is a 30-day window and the
287    /// circuit breaker has its own cooldown — the penalty outlives the fix: the
288    /// user tops up their credits and the router still avoids the models
289    /// (Parslee-ai/car#650). Distinct from `InferenceFailed` so the dispatch
290    /// loop can resolve it as an unattributed receipt instead.
291    ///
292    /// `provider` is the schema's provider label, so the dispatch loop can drop
293    /// every remaining candidate from the same account rather than replaying
294    /// the identical rejection down the fallback chain.
295    #[error("{provider} account rejected the request (HTTP {status}): {message}")]
296    ProviderAccount {
297        provider: String,
298        status: u16,
299        message: String,
300    },
301
302    /// A remote provider key could not be resolved before dispatch.
303    ///
304    /// Typed separately so outcome tracking can keep a missing key out of model
305    /// health and the per-model circuit breaker. Its Display deliberately
306    /// remains byte-identical to the former [`InferenceError::InferenceFailed`]
307    /// rendering: string consumers include the native coder's Parslee-only
308    /// wait-for-sign-in gate, and classifying an OpenRouter or generic provider
309    /// key as Parslee auth would wait on the wrong remedy (Parslee-ai/car#1544).
310    #[error("inference failed: {message}")]
311    ProviderKeyMissing {
312        provider: String,
313        model: String,
314        /// Stable schema order: primary key variable followed by alternatives;
315        /// the same order is preserved in diagnostics for deterministic output.
316        env_vars: Vec<String>,
317        /// The pre-existing human-facing error text, without the common
318        /// `inference failed: ` Display prefix.
319        message: String,
320    },
321
322    /// No usable credential for a provider — and *why*, as data rather than
323    /// prose.
324    ///
325    /// The message text already distinguished the cases (#803), but only in the
326    /// text: a consumer wanting to branch on "token aged out mid-run" versus
327    /// "never signed in" had to substring-match English that could be reworded
328    /// at any time. #797 asked for the distinction to be matchable
329    /// programmatically, which is what [`CredentialFailure`] is for.
330    ///
331    /// **The Display output opens with the historical prefix verbatim** —
332    /// `no credential for proprietary provider '<provider>'`. That is load
333    /// bearing, not cosmetic: `native_loop::is_auth_failure` (which drives the
334    /// wait-for-sign-in path) and the coder-ab harness's `INFRA_MARKERS` (which
335    /// keeps auth casualties out of a benchmark denominator) both classify on
336    /// it as a substring. Rewording the opening would silently reclassify auth
337    /// failures as ordinary errors in both.
338    #[error("no credential for proprietary provider '{provider}' (model {model}): {detail}")]
339    CredentialUnavailable {
340        provider: String,
341        model: String,
342        /// Machine-readable classification — branch on this, not on `detail`.
343        reason: CredentialFailure,
344        /// Human-facing explanation and remedy. Wording is not a contract.
345        detail: String,
346    },
347
348    /// The credential works, but the account behind it has no workspace, so
349    /// there is nothing to bill inference to and no org id to address it at.
350    ///
351    /// A **configuration** failure, deliberately not an auth one. The person is
352    /// signed in; telling them to sign in again is wrong advice, and the
353    /// coder's sign-in wait (`native_loop::is_auth_failure`, which classifies on
354    /// [`AUTH_FAILURE_MESSAGE_MARKERS`]) would park an unattended build on a
355    /// remedy that cannot resolve it. **The Display text is therefore required
356    /// to match no auth marker** — `workspace_required_reads_as_configuration_
357    /// not_sign_in` pins that. Rewording it needs that test re-run, not
358    /// overridden.
359    ///
360    /// Typed rather than left as `InferenceFailed` prose (its shape until
361    /// 2026-09-16) so the out-of-the-box agent can end a turn with the
362    /// `no_workspace` reason and a host can offer "finish setting up at
363    /// parslee.ai" instead of a sign-in button that leads back here.
364    #[error("{provider} account has no workspace yet: {detail}")]
365    WorkspaceRequired {
366        /// The provider whose account has no workspace (`parslee` today).
367        provider: String,
368        /// Human-facing explanation and remedy. Wording is not a contract.
369        detail: String,
370    },
371
372    /// The request was refused on **content** grounds by something in front of
373    /// the model — a gateway safety filter, not the model's own judgement.
374    ///
375    /// Distinct from `InferenceFailed` because the three things a caller wants
376    /// to do about it are all different from what they would do about a crash,
377    /// and all three were impossible while it looked like one
378    /// (Parslee-ai/car#796):
379    ///
380    /// - a **benchmark** can score it as a policy refusal instead of counting a
381    ///   crash, or silently inflating a pass rate by dropping it;
382    /// - a **retry loop** can stop, rather than burning its budget re-sending a
383    ///   decision that will never change;
384    /// - an **operator** can tell a content ruling from a misconfiguration.
385    ///
386    /// This says nothing about whether the refusal was *correct*. CAR is
387    /// reporting that something upstream declined the content, not endorsing the
388    /// call — an adversarial-safety suite is *supposed* to send input like this,
389    /// and a gateway that drops a variable fraction of it cannot be a substrate
390    /// for that measurement. Making the refusal legible is the part CAR owns.
391    #[error("{provider} refused this request on content grounds{}{}: {message}",
392        .kind.as_deref().map(|k| format!(" (type={k}")).unwrap_or_default(),
393        .code.as_deref().map(|c| format!(", code={c})")).unwrap_or_default())]
394    ContentRefused {
395        provider: String,
396        /// The gateway's own classification, when it sent one.
397        kind: Option<String>,
398        code: Option<String>,
399        message: String,
400    },
401
402    /// A managed gateway has no upstream configured for an entire namespace of
403    /// models it otherwise advertises.
404    ///
405    /// Environment-scoped, one level up from [`Self::ProviderAccount`]: the
406    /// account is fine and the credential is fine — the *deployment* was never
407    /// given an upstream to proxy to. Every model in the namespace fails it
408    /// identically, so none of them deserves the health penalty, and retrying
409    /// the next one down the fallback chain replays the same rejection.
410    ///
411    /// Kept distinct from `ProviderAccount` because the remedy is different and
412    /// belongs to a different person: an account rejection is the user's to fix
413    /// (top up credits, re-add a key), while this one is an operator
414    /// provisioning gap the user cannot act on at all. Collapsing them would
415    /// tell users to check a credential that is working.
416    ///
417    /// `namespace` is the model-id prefix the condition covers, so the dispatch
418    /// loop can drop every remaining candidate under it (Parslee-ai/car#786).
419    #[error("{provider} gateway has no upstream configured for '{namespace}' (HTTP {status}): {message}")]
420    GatewayUnconfigured {
421        provider: String,
422        namespace: String,
423        status: u16,
424        message: String,
425    },
426
427    #[error("tokenization error: {0}")]
428    TokenizationError(String),
429
430    #[error("device error: {0}")]
431    DeviceError(String),
432
433    #[error("io error: {0}")]
434    Io(#[from] std::io::Error),
435}
436
437impl From<resource_policy::LocalAdmissionError> for InferenceError {
438    fn from(error: resource_policy::LocalAdmissionError) -> Self {
439        let recovery = match error.preflight.verdict {
440            resource_policy::LocalLoadVerdict::DisabledByPolicy => {
441                "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()
442            }
443            resource_policy::LocalLoadVerdict::ExceedsConfiguredCeiling => format!(
444                "This model needs about {} MB for this request, beyond the configured {} MB local-model allocation. Increase the allocation or choose a smaller model.",
445                error.preflight.estimated_incremental_mb,
446                error.preflight.configured_ceiling_mb
447            ),
448            resource_policy::LocalLoadVerdict::InsufficientLiveMemory => format!(
449                "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.",
450                error.preflight.emergency_reserve_mb
451            ),
452            resource_policy::LocalLoadVerdict::LiveMemoryUnknown => {
453                "CAR could not measure live memory. The static allocation fits, but current safety is unknown.".to_string()
454            }
455            resource_policy::LocalLoadVerdict::ModelMaintenance => {
456                "This local model is being removed or maintained. Wait for that operation to finish, then retry.".to_string()
457            }
458            resource_policy::LocalLoadVerdict::PendingTeardown => {
459                "CAR is still confirming that the previous local model process exited. Wait for teardown to finish, then retry.".to_string()
460            }
461            resource_policy::LocalLoadVerdict::Allowed => error.to_string(),
462        };
463        Self::LocalResourceBlocked {
464            preflight: error.preflight,
465            recovery,
466        }
467    }
468}
469
470/// Whether a dispatch error should count against the model's circuit breaker.
471///
472/// The following classes are excluded because none is evidence about the model:
473///
474/// - [`InferenceError::UnsupportedMode`] is a **deterministic capability
475///   mismatch** — a JsonSchema `response_format` on Anthropic, or a video/audio
476///   block on a text-only provider — that will fail identically every time on
477///   THIS model, while the model stays perfectly healthy for other traffic.
478///   Feeding it to the breaker would trip a healthy model out of rotation for
479///   ALL requests, not just the incompatible ones.
480/// - [`InferenceError::ProviderAccount`] is an **account-wide** rejection —
481///   a bad key, or no credits. Every model on that account fails it and no
482///   model deserves the blame; benching them would outlive the billing fix
483///   (Parslee-ai/car#650).
484/// - [`InferenceError::CredentialUnavailable`] means authority resolution
485///   stopped the attempt before the model ran. Signed-out, expired, unreadable,
486///   missing-variable, and retryable-race reasons differ in remedy, but none is
487///   a model-health signal (Parslee-ai/car#1544).
488/// - [`InferenceError::ProviderKeyMissing`] is the same pre-dispatch boundary
489///   for a plain remote provider whose configured key variables resolved to
490///   nothing (Parslee-ai/car#1544).
491/// - [`InferenceError::WorkspaceRequired`] is the account's own setup, one
492///   step past a working credential: every model on that account fails it
493///   identically and none of them was given a chance, exactly as for the
494///   credential cases above.
495/// - [`InferenceError::GatewayUnconfigured`] is **environment-wide** — the
496///   deployment has no upstream to proxy to, so every model in the namespace
497///   fails identically and none of them was ever given a chance. Measured cost
498///   of not excluding it: ten managed aliases sitting at 52 calls / 0 successes
499///   in `car models stats`, a health record earned entirely by a
500///   misconfiguration (Parslee-ai/car#786).
501///
502/// Every other error is a genuine availability/health signal and still counts.
503fn error_counts_against_circuit_breaker(e: &InferenceError) -> bool {
504    !matches!(
505        e,
506        InferenceError::UnsupportedMode { .. }
507            | InferenceError::ProviderAccount { .. }
508            | InferenceError::ProviderKeyMissing { .. }
509            | InferenceError::CredentialUnavailable { .. }
510            | InferenceError::WorkspaceRequired { .. }
511            | InferenceError::GatewayUnconfigured { .. }
512            // A content refusal is a ruling about the REQUEST, not evidence
513            // about the model — which handles the same payload correctly when
514            // it gets through. Benching a model for what a filter in front of
515            // it decided would make an adversarial-safety suite progressively
516            // evict the models it is trying to measure (Parslee-ai/car#796).
517            | InferenceError::ContentRefused { .. }
518            | InferenceError::CatalogPreconditionMismatch { .. }
519            | InferenceError::ControlledTermination
520    )
521}
522
523/// Resolve one failed dispatch attempt into its outcome bucket.
524///
525/// Kept beside the circuit-breaker classifier so both model-health decisions
526/// are reviewable together: an unattributed receipt must never feed the
527/// per-model breaker while a genuine model failure must do both.
528fn record_dispatch_failure(tracker: &mut OutcomeTracker, trace_id: &str, error: &InferenceError) {
529    match error {
530        InferenceError::UnsupportedMode { .. } | InferenceError::ContentRefused { .. } => {
531            tracker.record_capability_rejection(trace_id, &error.to_string())
532        }
533        InferenceError::ProviderAccount { .. }
534        | InferenceError::ProviderKeyMissing { .. }
535        | InferenceError::CredentialUnavailable { .. }
536        | InferenceError::WorkspaceRequired { .. }
537        | InferenceError::GatewayUnconfigured { .. } => {
538            tracker.record_account_rejection(trace_id, &error.to_string())
539        }
540        _ => tracker.record_failure(trace_id, &error.to_string()),
541    }
542}
543
544/// Whether this failure ends the fallback chain instead of advancing it.
545///
546/// Every other condition the dispatch loop handles is about a *lane* — a dead
547/// credential, an unconfigured namespace, a capability the model lacks — and the
548/// right move is to try a different one. A content refusal is about the
549/// **request**, which is the one thing the chain cannot vary: each remaining
550/// candidate replays the identical payload the filter just declined.
551///
552/// So falling through does not merely waste attempts, it answers dishonestly. A
553/// remote-only chain has an installed on-device model appended as a last resort
554/// (see [`should_append_local_last_resort`]), and nothing is filtering that one —
555/// so a refusal of `parslee/reasoning` comes back as a *local* model's answer
556/// attributed to the model the caller asked for. That is the "manufactures fake
557/// results" failure the `strict_model` carve-out already exists to prevent, and
558/// it is fatal to the case #796 was filed from: an adversarial-safety benchmark
559/// drives this path deliberately, so a silent model swap inflates its pass rate
560/// and its run-to-run counts stop being reproducible.
561///
562/// Ending the chain surfaces `ContentRefused`, which the daemon returns as
563/// JSON-RPC `-32007` — a ruling the harness can score instead of a crash it has
564/// to guess at (Parslee-ai/car#796).
565fn error_ends_fallback_chain(e: &InferenceError) -> bool {
566    matches!(
567        e,
568        InferenceError::ContentRefused { .. }
569            | InferenceError::CatalogPreconditionMismatch { .. }
570            | InferenceError::ControlledTermination
571            // The account has no workspace. Every remaining candidate on that
572            // account meets the same wall, and the appended on-device last
573            // resort would answer as if it had not — the same silent-swap
574            // dishonesty the refusal case above ends the chain to prevent.
575            // The person has one thing to do, and continuing hides it.
576            | InferenceError::WorkspaceRequired { .. }
577    )
578}
579
580/// Apply the exhausted-chain recovery hints, which rewrite an opaque final error
581/// into one that names the two concrete things a user can do about it.
582///
583/// A content refusal is **exempt**. Both hints match on SUBSTRINGS of the Display
584/// text, and `ContentRefused` embeds the gateway's own message verbatim — so a
585/// refusal whose text happens to quote `403 forbidden` or `token expired` would
586/// be re-wrapped as `InferenceFailed` and lose its classification on the way out.
587/// That is the same drop-the-type mistake #796 was filed about, arriving one
588/// layer later. The variant already IS the answer here; there is nothing left to
589/// infer from its prose.
590fn apply_exhaustion_recovery_hint(underlying: InferenceError) -> InferenceError {
591    if matches!(
592        underlying,
593        InferenceError::ContentRefused { .. }
594            | InferenceError::CatalogPreconditionMismatch { .. }
595            | InferenceError::ControlledTermination
596            // Same reasoning, one variant later: `WorkspaceRequired` already
597            // names the only thing that resolves it, and both hints below
598            // would re-wrap it as `InferenceFailed` and lose the type the
599            // out-of-the-box agent's `no_workspace` refusal is built on.
600            | InferenceError::WorkspaceRequired { .. }
601    ) {
602        return underlying;
603    }
604    let underlying_str = underlying.to_string();
605    match no_backend_recovery_hint(&underlying_str)
606        .or_else(|| auth_expired_recovery_hint(&underlying_str))
607    {
608        Some(msg) => InferenceError::InferenceFailed(msg),
609        None => underlying,
610    }
611}
612
613const AUTH_LOGIN_MARKER: &str = "auth login";
614const AUTH_STORE_UNREADABLE_MARKER: &str = "credential store unreadable";
615const AUTH_ENV_MISSING_MARKER: &str = "credential environment variable missing";
616
617/// Every stable phrase which means a failure needs credential repair rather
618/// than an infrastructure retry. Route-level summaries use these same markers,
619/// and `is_auth_failure_message` is the workspace classifier that consumes the
620/// table.
621const AUTH_FAILURE_MESSAGE_MARKERS: &[&str] = &[
622    "no credential for proprietary",
623    AUTH_LOGIN_MARKER,
624    "session has expired",
625    "cannot read parslee credentials",
626    AUTH_STORE_UNREADABLE_MARKER,
627    AUTH_ENV_MISSING_MARKER,
628    // The two non-Parslee route summaries carry no `car auth login` remedy
629    // line, so without their own rows here they would render as credential
630    // failures the shared classifier calls infrastructure noise. Covers
631    // "{provider} credential was rejected for `x` (HTTP 401)" and
632    // "credential expired or was rejected for `x` — repair its provider login".
633    "credential was rejected",
634    "repair its provider login",
635];
636
637/// A credential failure from a configured or explicitly requested provider.
638///
639/// The fallback loop intentionally keeps trying after one account becomes
640/// unusable, but if every later candidate fails too, the final candidate's
641/// error is not the root cause the operator should fix first. Preserve the
642/// most recent actionable credential cause separately so a local resource
643/// error cannot overwrite an expired login in the final aggregate, while an
644/// ambient missing variable from an unconfigured fallback cannot overwrite
645/// the real terminal failure (Parslee-ai/car#1248).
646#[derive(Debug, Clone, PartialEq, Eq)]
647struct RouteCredentialFailure {
648    summary: String,
649    source_error: String,
650}
651
652fn parslee_signed_out_route_failure() -> RouteCredentialFailure {
653    let summary = "Parslee login is absent — run `car auth login` before retrying".to_string();
654    RouteCredentialFailure {
655        source_error: summary.clone(),
656        summary,
657    }
658}
659
660fn route_credential_failure(
661    candidate: &str,
662    error: &InferenceError,
663    promote_missing_credential: bool,
664) -> Option<String> {
665    match error {
666        InferenceError::CredentialUnavailable {
667            provider,
668            reason,
669            detail,
670            ..
671        } => {
672            let provider_name = if provider.eq_ignore_ascii_case("parslee") {
673                "Parslee".to_string()
674            } else {
675                provider.clone()
676            };
677            let summary = match reason {
678                CredentialFailure::Expired { .. } => format!(
679                    "{provider_name} login expired for `{candidate}` — run `car auth login`"
680                ),
681                CredentialFailure::SignedOut => format!(
682                    "{provider_name} login is absent for `{candidate}` — run `car auth login`"
683                ),
684                CredentialFailure::StoreUnreadable => format!(
685                    "{provider_name} {AUTH_STORE_UNREADABLE_MARKER} for `{candidate}` — unlock the credential store, then retry"
686                ),
687                CredentialFailure::EnvVarMissing { .. } if !promote_missing_credential => {
688                    return None;
689                }
690                CredentialFailure::EnvVarMissing { env_var } => format!(
691                    "{provider_name} {AUTH_ENV_MISSING_MARKER}: `{env_var}` for explicitly requested `{candidate}` — {detail}"
692                ),
693                // The authority re-read found a usable credential. This is a
694                // retry signal, not evidence that a person must repair auth.
695                CredentialFailure::RaceRetryable => return None,
696            };
697            Some(summary)
698        }
699        InferenceError::ProviderAccount {
700            provider, status, ..
701        } if matches!(*status, 401 | 403) => {
702            if provider.eq_ignore_ascii_case("parslee") {
703                Some(format!(
704                    "Parslee login expired or was rejected for `{candidate}` — run `car auth login`"
705                ))
706            } else {
707                Some(format!(
708                    "{provider} credential was rejected for `{candidate}` (HTTP {status})"
709                ))
710            }
711        }
712        _ => {
713            let rendered = error.to_string();
714            let lower = rendered.to_ascii_lowercase();
715            if is_auth_rejection_message(&rendered) {
716                if candidate
717                    .split_once('/')
718                    .is_some_and(|(provider, _)| provider.eq_ignore_ascii_case("parslee"))
719                {
720                    Some(format!(
721                        "Parslee login expired or was rejected for `{candidate}` — run `car auth login`"
722                    ))
723                } else {
724                    Some(format!(
725                        "credential expired or was rejected for `{candidate}` — repair its provider login"
726                    ))
727                }
728            } else if lower.contains(AUTH_STORE_UNREADABLE_MARKER)
729                || (promote_missing_credential && lower.contains("keychain lookup failed"))
730            {
731                Some(format!(
732                    "{AUTH_STORE_UNREADABLE_MARKER} for `{candidate}` — unlock the credential store, then retry"
733                ))
734            } else {
735                None
736            }
737        }
738    }
739}
740
741fn record_route_credential_failure(
742    slot: &mut Option<RouteCredentialFailure>,
743    candidate: &str,
744    error: &InferenceError,
745    promote_missing_credential: bool,
746) {
747    if let Some(summary) = route_credential_failure(candidate, error, promote_missing_credential) {
748        // Last actionable credential failure wins. In preference order it is
749        // the terminal configured/attempted provider, while steady-state
750        // EnvVarMissing noise from an unconfigured fallback never enters the
751        // slot unless that provider was explicitly requested.
752        *slot = Some(RouteCredentialFailure {
753            summary,
754            source_error: error.to_string(),
755        });
756    }
757}
758
759/// The routing snapshot probes only Parslee's credential store. Preserve its
760/// failure only when a Parslee route participates; another provider's API key
761/// cannot be repaired by signing in to Parslee. Actual attempted provider auth
762/// failures are recorded separately by `record_route_credential_failure`.
763fn chain_includes_parslee_route<'a>(
764    mut resolve: impl FnMut(&str) -> Option<&'a ModelSchema>,
765    chain: &[String],
766) -> bool {
767    chain.iter().any(|candidate| {
768        resolve(candidate).is_some_and(|schema| schema.provider.eq_ignore_ascii_case("parslee"))
769    })
770}
771
772/// Apply a remembered route-level credential cause before the exhausted-chain
773/// hint. The credential summary is deliberately first and the final
774/// candidate's error remains secondary detail, so mixed auth + OOM failures
775/// tell the operator to repair the login rather than resize a local model.
776///
777/// The underlying error keeps its TYPE. Downstream consumers branch on the
778/// variant, not the prose — `car run-task` re-runs an
779/// [`InferenceError::Transient`], account-wide handling keys on
780/// [`InferenceError::ProviderAccount`], and
781/// [`InferenceError::CredentialUnavailable`] carries [`CredentialFailure`] as
782/// data — so the credential context is folded into the variant's human-facing
783/// message field instead of collapsing everything to `InferenceFailed`. Only
784/// variants with no augmentable message field fall back to a stringified
785/// `InferenceFailed`.
786///
787/// A fresh install exhausts with "no models / no backend" errors; the
788/// credential cause does not replace [`no_backend_recovery_hint`]'s setup
789/// guidance, because an operator who cannot log in still needs the on-device
790/// `car models pull` path.
791fn apply_route_failure_context(
792    underlying: InferenceError,
793    credential: Option<&RouteCredentialFailure>,
794) -> InferenceError {
795    if matches!(
796        underlying,
797        InferenceError::ContentRefused { .. }
798            | InferenceError::CatalogPreconditionMismatch { .. }
799            | InferenceError::ControlledTermination
800            // `WorkspaceRequired` has no message field to fold context into,
801            // so without this it would fall to the wildcard at the bottom of
802            // the match and come back out as `InferenceFailed` — type erased —
803            // whenever an EARLIER candidate in the chain recorded a credential
804            // failure. Every exhausted chain's final error passes through here,
805            // so that is the ordinary mixed-chain case, not a corner:
806            // `workspace_required_survives_a_mixed_chain` pins it.
807            | InferenceError::WorkspaceRequired { .. }
808    ) {
809        return underlying;
810    }
811    let Some(credential) = credential else {
812        return apply_exhaustion_recovery_hint(underlying);
813    };
814    let rendered = underlying.to_string();
815    let summary = credential.summary.as_str();
816    let setup_hint = no_backend_recovery_hint(&rendered);
817    let connective = if rendered == credential.source_error {
818        "provider detail"
819    } else {
820        "fallback then failed"
821    };
822    let augment = |field: String| match &setup_hint {
823        // The hint already embeds the underlying error verbatim.
824        Some(hint) => format!("{summary}; {hint}"),
825        None => format!("{summary}; {connective}: {field}"),
826    };
827    match underlying {
828        InferenceError::InferenceFailed(message) => {
829            InferenceError::InferenceFailed(augment(message))
830        }
831        InferenceError::ProviderKeyMissing {
832            provider,
833            model,
834            env_vars,
835            message,
836        } => InferenceError::ProviderKeyMissing {
837            provider,
838            model,
839            env_vars,
840            message: augment(message),
841        },
842        InferenceError::Transient { status, message } => InferenceError::Transient {
843            status,
844            message: augment(message),
845        },
846        InferenceError::ProviderAccount {
847            provider,
848            status,
849            message,
850        } => InferenceError::ProviderAccount {
851            provider,
852            status,
853            message: augment(message),
854        },
855        InferenceError::CredentialUnavailable {
856            provider,
857            model,
858            reason,
859            detail,
860        } => InferenceError::CredentialUnavailable {
861            provider,
862            model,
863            reason,
864            detail: augment(detail),
865        },
866        InferenceError::GatewayUnconfigured {
867            provider,
868            namespace,
869            status,
870            message,
871        } => InferenceError::GatewayUnconfigured {
872            provider,
873            namespace,
874            status,
875            message: augment(message),
876        },
877        InferenceError::LocalResourceBlocked {
878            preflight,
879            recovery,
880        } => InferenceError::LocalResourceBlocked {
881            preflight,
882            recovery: augment(recovery),
883        },
884        // No message field to fold the context into — the stringified wrap is
885        // the only remaining honest rendering.
886        _ => InferenceError::InferenceFailed(augment(rendered)),
887    }
888}
889
890/// Why a credential was unusable, as data — see
891/// [`InferenceError::CredentialUnavailable`].
892///
893/// These need *different remedies*, which is the whole reason they are
894/// separated: re-authenticating fixes `SignedOut` and `Expired`, does nothing
895/// for `StoreUnreadable` (unlock the keychain), and is the wrong advice
896/// entirely for `EnvVarMissing` (set the variable). A long job that dies on one
897/// while being told to do the other is Parslee-ai/car#797.
898#[derive(Debug, Clone, PartialEq, Eq)]
899pub enum CredentialFailure {
900    /// A Parslee session existed and its access token aged out; refresh did not
901    /// yield a new one. `expires_at` is unix seconds.
902    ///
903    /// The distinguishing case from #797: the account is *fine*, it is the run
904    /// that outlived the token. Consumers that can checkpoint should treat this
905    /// as resumable-after-reauth rather than as a hard configuration error.
906    Expired { expires_at: u64 },
907    /// A published tombstone: no account is active. The only state that
908    /// genuinely means "log in".
909    SignedOut,
910    /// The credential store could not be read (locked keychain, helper
911    /// timeout). Says nothing about whether credentials exist — notably NOT a
912    /// sign-out, and re-authenticating is the wrong reflex.
913    StoreUnreadable,
914    /// A plain env/keychain-backed provider whose variable did not resolve.
915    EnvVarMissing { env_var: String },
916    /// The store reported an active session on the failure-path re-read — a
917    /// race between the two reads, so the request is worth retrying.
918    RaceRetryable,
919}
920
921/// Which device to run inference on.
922#[derive(Debug, Clone, Copy, PartialEq, Eq)]
923pub enum Device {
924    Cpu,
925    Metal,
926    Cuda(usize), // device ordinal
927}
928
929impl Device {
930    /// Auto-detect the best available device for this platform.
931    ///
932    /// macOS uses MLX (Metal); x86_64 Linux and Windows are compiled with
933    /// candle CUDA, so we prefer the GPU there and let `to_candle_device`'s
934    /// `cuda_if_available` transparently fall back to CPU on a box with no
935    /// NVIDIA GPU. aarch64 Linux and other targets run CPU candle. See
936    /// `project_local_inference_gpu_only`.
937    pub fn auto() -> Self {
938        #[cfg(all(target_os = "macos", feature = "metal"))]
939        {
940            return Device::Metal;
941        }
942        #[cfg(all(
943            any(target_os = "linux", target_os = "windows"),
944            target_arch = "x86_64",
945            not(car_skip_cuda)
946        ))]
947        {
948            return Device::Cuda(0);
949        }
950        #[cfg(not(any(
951            all(target_os = "macos", feature = "metal"),
952            all(
953                any(target_os = "linux", target_os = "windows"),
954                target_arch = "x86_64",
955                not(car_skip_cuda)
956            )
957        )))]
958        {
959            Device::Cpu
960        }
961    }
962}
963
964/// Configuration for the inference engine.
965#[derive(Debug, Clone)]
966pub struct InferenceConfig {
967    /// Where to store downloaded models. Defaults to ~/.car/models/
968    ///
969    /// This is a machine-global weight cache, NOT per-daemon state: it stays
970    /// shared even when `CAR_HOME` relocates a daemon. Anything CAR *writes*
971    /// about itself belongs under [`state_root`](Self::state_root) instead.
972    pub models_dir: std::path::PathBuf,
973    /// CAR state root: `$CAR_HOME`, else `~/.car`.
974    ///
975    /// Everything the engine persists about itself — the routing outcome
976    /// profiles, the receipt ledger, key-pool stats, the signed-catalog cache,
977    /// the discovery cache, the user `models.json` — hangs off this. Two
978    /// daemons with different roots therefore keep separate bookkeeping while
979    /// still sharing the multi-gigabyte weights in `models_dir`. With `CAR_HOME`
980    /// unset this is `~/.car`, exactly where all of those files already are.
981    pub state_root: std::path::PathBuf,
982    /// Device override. None = auto-detect.
983    pub device: Option<Device>,
984    /// Default model for generation tasks.
985    pub generation_model: String,
986    /// Optional preferred model override for generation tasks.
987    pub preferred_generation_model: Option<String>,
988    /// Default model for embedding tasks.
989    pub embedding_model: String,
990    /// Optional preferred model override for embedding tasks.
991    pub preferred_embedding_model: Option<String>,
992    /// Default model for classification tasks.
993    pub classification_model: String,
994    /// Optional preferred model override for classification tasks.
995    pub preferred_classification_model: Option<String>,
996}
997
998impl Default for InferenceConfig {
999    fn default() -> Self {
1000        // `models_dir` is deliberately anchored at `$HOME/.car`, NOT at the
1001        // `car_home` state root: model weights (and the python runtimes beside
1002        // them) are a multi-gigabyte machine-global cache of byte-identical
1003        // files, not per-instance state. A daemon relocated with `CAR_HOME`
1004        // moves its own journals, prefs and caches, and goes on sharing these —
1005        // the alternative is re-downloading tens of gigabytes to end up with
1006        // the same bytes in a second place.
1007        //
1008        // Only the weights get that treatment. Everything the engine writes
1009        // about *itself* — the outcome profiles and receipt ledger, key-pool
1010        // stats, the signed-catalog cache, the discovery cache, the user
1011        // `models.json` — resolves under `state_root` below, so a relocated
1012        // daemon keeps its own copy. Several of those files historically sat
1013        // inside `models/`; they still do, just under `state_root/models`
1014        // rather than under the weights dir, which is the same directory
1015        // whenever `CAR_HOME` is unset.
1016        let models_dir = default_models_dir();
1017
1018        let hw = HardwareInfo::detect();
1019
1020        Self {
1021            models_dir,
1022            state_root: car_home::root_or_relative(),
1023            device: None,
1024            generation_model: hw.recommended_model,
1025            preferred_generation_model: None,
1026            embedding_model: "Qwen3-Embedding-0.6B".to_string(),
1027            preferred_embedding_model: None,
1028            classification_model: "Qwen3-0.6B".to_string(),
1029            preferred_classification_model: None,
1030        }
1031    }
1032}
1033
1034impl InferenceConfig {
1035    /// Engine state that has always lived beside the weights, kept at the same
1036    /// relative path (`models/`) but anchored at
1037    /// [`state_root`](Self::state_root): the outcome profiles, the receipt
1038    /// ledger, key-pool stats, benchmark priors, the discovery cache.
1039    ///
1040    /// Identical to [`models_dir`](Self::models_dir) whenever `CAR_HOME` is
1041    /// unset — which is why no existing install's files move — and a separate,
1042    /// per-daemon directory once it is set.
1043    pub fn state_models_dir(&self) -> std::path::PathBuf {
1044        self.state_root.join("models")
1045    }
1046}
1047
1048/// The machine-shared model-weight cache: `$HOME/.car/models`
1049/// (`$USERPROFILE` on Windows), cwd-relative only when neither resolves.
1050///
1051/// Deliberately NOT `car_home`-anchored — see [`InferenceConfig::models_dir`].
1052/// Exposed so `car doctor` can check the weights the running daemon actually
1053/// loads, rather than the (possibly relocated) state root's `models/`.
1054pub fn default_models_dir() -> std::path::PathBuf {
1055    dirs_next()
1056        .unwrap_or_else(|| std::path::PathBuf::from("."))
1057        .join(".car")
1058        .join("models")
1059}
1060
1061fn dirs_next() -> Option<std::path::PathBuf> {
1062    // `HOME`, falling back to `USERPROFILE` on Windows (where `HOME` is normally
1063    // unset) — the same fallback used across the workspace. Without it every
1064    // `InferenceConfig::default()` resolves `models_dir` CWD-relative on Windows.
1065    std::env::var_os("HOME")
1066        .or_else(|| std::env::var_os("USERPROFILE"))
1067        .map(std::path::PathBuf::from)
1068}
1069
1070fn model_source_identity(schema: &ModelSchema) -> &str {
1071    match &schema.source {
1072        ModelSource::Local { hf_repo, .. }
1073        | ModelSource::Mlx { hf_repo, .. }
1074        | ModelSource::ManagedVllmMlx { hf_repo, .. } => hf_repo,
1075        ModelSource::WhisperCpp { model } | ModelSource::CodexCli { model } => model,
1076        _ => &schema.id,
1077    }
1078}
1079
1080/// Token usage statistics from a model response.
1081#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
1082pub struct TokenUsage {
1083    /// Number of tokens in the prompt/input.
1084    ///
1085    /// For providers with prompt caching (Anthropic), this is the
1086    /// *non-cached* prefix only — the tokens after the last cache
1087    /// breakpoint. The cached portion is reported separately in
1088    /// [`Self::cache_read_input_tokens`] / [`Self::cache_creation_input_tokens`],
1089    /// so the true input total is the sum of all three. Pricing those
1090    /// three buckets at the same rate over- or under-counts cost; see
1091    /// [`crate::outcome::ModelProfile::usd_per_success`].
1092    pub prompt_tokens: u64,
1093    /// Number of tokens in the completion/output.
1094    pub completion_tokens: u64,
1095    /// Total tokens (prompt + completion).
1096    pub total_tokens: u64,
1097    /// Model's maximum context window size.
1098    pub context_window: u64,
1099    /// Prompt-cache hit: input tokens read from a previously written cache
1100    /// entry. Billed at ~0.1× the base input rate. `0` when the provider
1101    /// has no prompt caching, caching was disabled, or nothing hit.
1102    /// (Anthropic `usage.cache_read_input_tokens`.)
1103    #[serde(default)]
1104    pub cache_read_input_tokens: u64,
1105    /// Prompt-cache write: input tokens written into the cache this request.
1106    /// Billed at ~1.25× (5-minute TTL) or ~2× (1-hour TTL) the base input
1107    /// rate. `0` when caching is off or nothing was written.
1108    /// (Anthropic `usage.cache_creation_input_tokens`.)
1109    #[serde(default)]
1110    pub cache_creation_input_tokens: u64,
1111}
1112
1113/// Result of an inference call, including trace ID for outcome tracking.
1114#[derive(
1115    Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq, schemars::JsonSchema,
1116)]
1117pub struct InferenceModelIdentity {
1118    /// Exact immutable model id supplied through the protocol `model_id` pin.
1119    /// `None` for adaptive routing and legacy display-name/alias requests.
1120    #[serde(default)]
1121    pub requested_model_id: Option<String>,
1122    /// Canonical immutable catalog id actually used after routing/fallback.
1123    #[serde(default)]
1124    pub resolved_model_id: String,
1125    /// SHA-256 digest of the resolved immutable `ModelSchema` row.
1126    #[serde(default)]
1127    pub row_digest: String,
1128    /// SHA-256 revision of the exact catalog snapshot captured for this call.
1129    #[serde(default)]
1130    pub catalog_revision: String,
1131}
1132
1133/// Result of an inference call, including trace ID for outcome tracking.
1134#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
1135pub struct InferenceResult {
1136    /// The generated text (empty if tool_calls are present).
1137    pub text: String,
1138    /// Tool calls returned by the model (when tools were provided in the request).
1139    pub tool_calls: Vec<crate::tasks::generate::ToolCall>,
1140    /// Structured bounding boxes when the model emitted Qwen2.5-VL
1141    /// grounding spans (`<|box_*|>`, `<|object_ref_*|>`) in its text.
1142    /// Parsed from the same `text` field — the raw span markers remain
1143    /// visible in `text` for callers that need to see them verbatim.
1144    /// Empty vec when the model didn't ground anything (typical for
1145    /// non-VL models or prompts that only ask for description).
1146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1147    pub bounding_boxes: Vec<crate::tasks::grounding::BoundingBox>,
1148    /// Trace ID for reporting outcomes back to the tracker.
1149    pub trace_id: String,
1150    /// Which model was used. Exact catalog-id pins report the immutable
1151    /// resolved id; legacy/adaptive routes retain their display-name behavior.
1152    pub model_used: String,
1153    /// Immutable catalog identity, flattened onto the v3 inference result so
1154    /// callers can validate the route without another lookup.
1155    #[serde(flatten)]
1156    pub model_identity: InferenceModelIdentity,
1157    /// Wall-clock latency in ms.
1158    pub latency_ms: u64,
1159    /// Time to first token in milliseconds. Populated by the local
1160    /// generate paths (Candle/MLX) which observe the prefill→first-decode
1161    /// transition directly. `None` for paths that can't measure it
1162    /// honestly without streaming — currently the non-streaming remote
1163    /// paths. Callers needing TTFT on remote models should use
1164    /// [`InferenceEngine::generate_tracked_stream`] and time the first
1165    /// `text` event arrival themselves.
1166    ///
1167    /// Always serialized (as `null` when `None`) so downstream
1168    /// validation harnesses can distinguish "wasn't measured" from
1169    /// "field doesn't exist on this client's protocol version".
1170    #[serde(default)]
1171    pub time_to_first_token_ms: Option<u64>,
1172    /// Token usage for the call. Populated by the remote providers from their
1173    /// API response, and by the local backends from their own decode loops —
1174    /// the in-process MLX and candle paths report the post-truncation prompt
1175    /// length and the number of tokens they sampled, and the mlx-vlm CLI path
1176    /// reports the counts the CLI prints (image patches included).
1177    ///
1178    /// `None` means nobody could report a count, and it is deliberately not a
1179    /// zeroed struct: a consumer summing `total_tokens` cannot tell a
1180    /// fabricated `0` from a real "this used no tokens", so an absent count is
1181    /// the honest answer and lets callers fall back to their own estimator
1182    /// (Parslee-ai/car#795). Still `None` on: FoundationModels (Apple's
1183    /// on-device framework exposes no token counts), a delegated runner that
1184    /// emits no `usage` stream event, and an mlx-vlm build whose performance
1185    /// summary doesn't parse.
1186    ///
1187    /// [`TokenUsage::context_window`] is `0` on the streaming path — the
1188    /// accumulator builds usage from stream events, which carry no model
1189    /// metadata. Non-streaming calls populate it.
1190    pub usage: Option<TokenUsage>,
1191    /// Provider-specific output items the protocol emitted alongside
1192    /// the response — currently used by the OpenAI Responses API to
1193    /// return reasoning blobs, encrypted_content, web-search results,
1194    /// etc. as opaque structured items the next request must include
1195    /// verbatim. Empty for protocols that don't emit them (Chat
1196    /// Completions, Anthropic, Gemini, all local backends).
1197    ///
1198    /// Callers carry these between turns by emitting them as a
1199    /// [`tasks::generate::Message::ProviderOutputItems`] message in
1200    /// the next request. Builder paths that don't recognize the
1201    /// originating protocol drop the variant — the items are
1202    /// protocol-specific and have no portable rendering.
1203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1204    pub provider_output_items: Vec<serde_json::Value>,
1205    /// Extended-thinking blocks the model produced this turn (Anthropic adaptive
1206    /// thinking). Captured verbatim (text + opaque signature) so the caller can
1207    /// attach them to the replayed
1208    /// [`tasks::generate::Message::Assistant`] and preserve them on the next
1209    /// turn — Anthropic 400s if prior thinking blocks aren't sent back
1210    /// unchanged before the tool_use blocks. Empty for providers/models without
1211    /// thinking (Chat Completions, Gemini, all local backends).
1212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1213    pub thinking: Vec<crate::tasks::generate::ThinkingBlock>,
1214    /// Why generation stopped. For remote models this is the raw
1215    /// provider string (OpenAI `finish_reason`, Anthropic `stop_reason`,
1216    /// Google `finishReason`). For local Qwen3 hybrid-thinking models the
1217    /// runtime also sets it for its reasoning-recovery path
1218    /// (car-releases#60): `"thinking_recovered"` when reasoning consumed
1219    /// the whole token budget inside an unclosed `<think>` block and the
1220    /// runtime retried with reasoning suppressed to produce a direct
1221    /// answer, or `"thinking_truncated"` when even that retry was empty.
1222    /// A model decoded in-process also reports
1223    /// `"local_decode_timeout"` ([`LOCAL_DECODE_TIMEOUT_STOP_REASON`]) when the
1224    /// wall-clock ceiling cut the pass short (car#851).
1225    /// `None` for an ordinary local completion or a provider that didn't
1226    /// report one. Always serialized (as `null` when `None`) so the wire
1227    /// contract is stable — see the `inference_result_serializes_*` tests.
1228    /// Use [`InferenceResult::was_truncated`] to detect a cut-short response.
1229    #[serde(default)]
1230    pub stop_reason: Option<String>,
1231    /// The candidate that was skipped because its credential was REJECTED
1232    /// (not merely absent), when a later candidate in the fallback chain
1233    /// then succeeded. `None` on the common path.
1234    ///
1235    /// Exists so a caller can ANNOUNCE the degrade instead of silently
1236    /// serving a different model: an operator whose Parslee sign-in lapsed
1237    /// otherwise sees a working run on a fallback backbone with no hint
1238    /// that the lane they configured is dead (Parslee-ai/car#888).
1239    #[serde(default, skip_serializing_if = "Option::is_none")]
1240    pub auth_fallback_from: Option<String>,
1241    /// True when this turn was served by the installed on-device model that
1242    /// CAR appended behind an otherwise remote-only fallback chain.
1243    ///
1244    /// This is distinct from merely using a local model: an explicitly chosen
1245    /// local primary is ordinary routing. Callers should surface this marker so
1246    /// a resilience fallback cannot masquerade as the preferred remote model.
1247    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1248    pub local_last_resort: bool,
1249    /// Every candidate the chain moved past, in order, and WHY. Empty when the
1250    /// first candidate served.
1251    ///
1252    /// The general form of [`InferenceResult::auth_fallback_from`], which
1253    /// answers only "was a credential rejected". A run whose backbone changed
1254    /// mid-session because of a rate limit, a timeout, or an absent credential
1255    /// had no reason recorded anywhere at all — so a surprising result could be
1256    /// attributed to the code under test when the real cause was that a
1257    /// different model wrote it (Parslee-ai/car#1351).
1258    ///
1259    /// **Not a superset of `auth_fallback_from`, even though it holds every
1260    /// hop.** [`FallbackReason::CredentialRejected`] is deliberately broader
1261    /// than that field's predicate: it includes a provider refusing an API key
1262    /// (`ProviderAccount` 401), whose remedy is to fix the key.
1263    /// `auth_fallback_from` names only the narrower set a person clears by
1264    /// signing in, because the announcement it drives says `car auth login` —
1265    /// and telling someone to sign in over a bad OpenAI key is the wrong
1266    /// remedy (Parslee-ai/car#888). Recording both keeps the journal general
1267    /// without making the announcement wrong.
1268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1269    pub fallback_from: Vec<FallbackFrom>,
1270}
1271
1272/// A candidate the fallback chain moved past, and why.
1273#[derive(
1274    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
1275)]
1276pub struct FallbackFrom {
1277    /// The candidate's name, as the chain knew it.
1278    pub candidate: String,
1279    pub reason: FallbackReason,
1280}
1281
1282/// Why the chain moved past a candidate.
1283///
1284/// Coarse on purpose. The point is to distinguish causes an operator would ACT
1285/// on differently — sign in, wait, configure a key, look at the provider — not
1286/// to reproduce every provider's error taxonomy. Anything unrecognized is
1287/// [`FallbackReason::Failed`] rather than being forced into a bucket it does
1288/// not belong in.
1289#[derive(
1290    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
1291)]
1292#[serde(rename_all = "snake_case")]
1293pub enum FallbackReason {
1294    /// A credential exists and was REFUSED — an expired Parslee session, or a
1295    /// provider rejecting an API key (`ProviderAccount` 401/403).
1296    ///
1297    /// Deliberately broader than [`InferenceResult::auth_fallback_from`]'s
1298    /// predicate, which names only the subset a person clears by signing in.
1299    /// `car auth login` does not fix a bad OpenAI key, so this variant must not
1300    /// be read as "run that" — the remedy depends on which credential was
1301    /// refused.
1302    CredentialRejected,
1303    /// No credential is configured for that lane at all. A different fix from
1304    /// `CredentialRejected`: nothing expired, nothing was ever set.
1305    CredentialAbsent,
1306    /// The provider rate-limited the call (429 / "too many requests"). Clears
1307    /// by waiting.
1308    RateLimited,
1309    /// The account is out of credits or over quota (402).
1310    ///
1311    /// Separate from [`FallbackReason::RateLimited`] because an empty balance
1312    /// does NOT clear by waiting — the remedy is to top up. Folding it in told
1313    /// an operator to wait out a billing problem.
1314    QuotaExhausted,
1315    /// The call exceeded its deadline.
1316    ///
1317    /// Only when a deadline is what was actually hit. A transport failure with
1318    /// no status — connection refused, DNS, TLS, a truncated body — is
1319    /// [`FallbackReason::Failed`], because telling someone their call timed out
1320    /// when the endpoint was never up sends them to raise a timeout instead of
1321    /// starting the runtime.
1322    TimedOut,
1323    /// Anything else — a 5xx, a malformed request, a panicked runner, an
1324    /// unreadable credential store. The honest bucket, and it has to STAY
1325    /// honest: a first version of this classifier matched substrings and
1326    /// silently absorbed every `ProviderAccount` rejection here, which is the
1327    /// opposite of what this variant is for.
1328    Failed,
1329}
1330
1331/// Classify a candidate's failure into a [`FallbackReason`].
1332///
1333/// **On the TYPED error, not on its rendered prose.** Every discriminating fact
1334/// here already exists as data — `ProviderAccount` carries `status: u16`,
1335/// `CredentialUnavailable` carries `reason: CredentialFailure`, `Transient`
1336/// carries `status: Option<u16>` — and `CredentialFailure`'s own doc says why
1337/// (car#797): a consumer wanting to branch on "token aged out mid-run" versus
1338/// "never signed in" had to substring-match English that could be reworded at
1339/// any time.
1340///
1341/// A first version of this DID substring-match the English, and got four things
1342/// wrong that this file had already fixed twice elsewhere:
1343///
1344/// - `ProviderAccount` 401 renders `"… account rejected the request (HTTP 401):
1345///   provider rejected the API key …"` — no "unauthorized" token, so the
1346///   substring rule missed it and a REFUSED API KEY, the most actionable
1347///   degrade there is, journaled as `Failed`;
1348/// - a 400 whose provider body merely quotes "401 Unauthorized" classified as
1349///   `CredentialRejected`, sending an operator to re-auth over a malformed
1350///   request. `is_provider_transient` anchors on [`parse_api_returned_status`]
1351///   for exactly this reason, and `remote::is_auth_rejection` anchors on
1352///   "HTTP 401" with a test pinning the quoting case;
1353/// - `Parslee org lookup failed: HTTP 429 …` classified as `CredentialRejected`,
1354///   though the producer deliberately gates `note_credential_rejected()` on
1355///   401/403 because a 429 is not a dead credential;
1356/// - a locked keychain (`CredentialFailure::StoreUnreadable`) classified as
1357///   `CredentialAbsent`, whose remedy — configure a key — is wrong for a
1358///   credential that exists and cannot be read.
1359///
1360/// Only [`InferenceError::InferenceFailed`] falls through to a string sniff,
1361/// because it is the one variant carrying no structure, and that sniff is
1362/// status-anchored rather than substring-anchored.
1363pub fn classify_fallback_reason(error: &InferenceError) -> FallbackReason {
1364    use FallbackReason as R;
1365    match error {
1366        // 401/403 = the key was refused; 402 = the account is out of credit,
1367        // which is a "wait / top up" not a "your credential is wrong".
1368        InferenceError::ProviderAccount { status, .. } => match status {
1369            402 => R::QuotaExhausted,
1370            _ => R::CredentialRejected,
1371        },
1372        // Branch on the machine-readable classification, per its own contract.
1373        InferenceError::CredentialUnavailable { reason, .. } => match reason {
1374            // The account is fine; the run outlived the token.
1375            CredentialFailure::Expired { .. } => R::CredentialRejected,
1376            // Nothing was rejected — there is simply nothing there.
1377            CredentialFailure::SignedOut | CredentialFailure::EnvVarMissing { .. } => {
1378                R::CredentialAbsent
1379            }
1380            // Says NOTHING about whether a credential exists. Neither absent
1381            // nor rejected, so the honest bucket beats a wrong remedy.
1382            CredentialFailure::StoreUnreadable | CredentialFailure::RaceRetryable => R::Failed,
1383        },
1384        InferenceError::Transient {
1385            status: Some(429), ..
1386        } => R::RateLimited,
1387        // "Transport OR timeout", per the variant's own doc — and the two are
1388        // not distinguishable here. `reqwest_error_is_transient` admits
1389        // connect-refused, DNS, TLS and truncated-body errors alongside real
1390        // timeouts, and `TransportAttemptError` drops the `is_timeout` flag
1391        // that would separate them. Calling all of that `TimedOut` tells an
1392        // operator whose local runtime never started to raise a timeout.
1393        InferenceError::Transient { status: None, .. } => R::Failed,
1394        InferenceError::Transient { .. } => R::Failed,
1395        // The one case that IS unambiguously a deadline: the caller's own
1396        // armed `infer.deadline` elapsed, and the error says which one.
1397        InferenceError::DeadlineExceeded { .. } => R::TimedOut,
1398        InferenceError::InferenceFailed(msg)
1399        | InferenceError::ProviderKeyMissing { message: msg, .. } => classify_untyped_failure(msg),
1400        _ => R::Failed,
1401    }
1402}
1403
1404/// The string fallback for [`InferenceError::InferenceFailed`], which carries
1405/// no structure.
1406///
1407/// Status-anchored wherever a status exists. A provider's error body is text we
1408/// did not write: it can quote any number or phrase, and
1409/// `apply_exhaustion_recovery_hint` already exempts `ContentRefused` from
1410/// substring hints for precisely that reason.
1411fn classify_untyped_failure(msg: &str) -> FallbackReason {
1412    use FallbackReason as R;
1413    // `API returned <status>: <verbatim provider body>` — trust the status,
1414    // never the body.
1415    if let Some(status) = parse_api_returned_status(msg) {
1416        return match status {
1417            401 | 403 => R::CredentialRejected,
1418            402 => R::QuotaExhausted,
1419            429 => R::RateLimited,
1420            408 | 504 => R::TimedOut,
1421            _ => R::Failed,
1422        };
1423    }
1424    let l = msg.to_ascii_lowercase();
1425    // `Parslee org lookup failed: HTTP <status>: <body>` is emitted for ANY
1426    // non-success status, so the phrase alone does not mean a dead credential.
1427    if l.contains("org lookup failed") {
1428        return if l.contains("http 401") || l.contains("http 403") {
1429            R::CredentialRejected
1430        } else if l.contains("http 429") {
1431            R::RateLimited
1432        } else {
1433            R::Failed
1434        };
1435    }
1436    if l.contains("authentication required")
1437        || l.contains("invalid_grant")
1438        || l.contains("token expired")
1439    {
1440        return R::CredentialRejected;
1441    }
1442    if l.contains("no credential for proprietary") || l.contains("no api key") {
1443        return R::CredentialAbsent;
1444    }
1445    if l.contains("too many requests") || l.contains("rate limit") {
1446        return R::RateLimited;
1447    }
1448    if l.contains("timed out") || l.contains("deadline exceeded") {
1449        return R::TimedOut;
1450    }
1451    R::Failed
1452}
1453
1454/// Append `candidate` to the chain's hop list, with why it was skipped.
1455///
1456/// **Every hop, in order** — not first-wins. A chain that skips lanes 1, 2 and 3
1457/// before lane 4 serves made three transitions, and a single slot records one of
1458/// them while the journal downstream claims to hold every transition.
1459pub fn record_fallback_from(hops: &mut Vec<FallbackFrom>, candidate: &str, error: &InferenceError) {
1460    hops.push(FallbackFrom {
1461        candidate: candidate.to_string(),
1462        reason: classify_fallback_reason(error),
1463    });
1464}
1465
1466/// Handle returned by [`InferenceEngine::generate_tracked_stream`]: the event
1467/// receiver plus the stream-level metadata a caller needs to attribute the
1468/// finished turn. `trace_id` is the same trace the tap task resolves on
1469/// completion, so a caller can score the turn against it; `model_used` is the
1470/// resolved model. In-process only (the receiver isn't serializable) — the wire
1471/// layer forwards events and surfaces these fields on the final response itself.
1472pub struct TrackedStream {
1473    /// Resolved model id for this stream.
1474    pub model_used: String,
1475    /// Trace id (minted before the first token) the tap resolves on completion.
1476    pub trace_id: String,
1477    /// The forwarded event stream.
1478    pub events: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
1479}
1480
1481struct AbortOnDropTask<T>(Option<tokio::task::JoinHandle<T>>);
1482
1483impl<T> AbortOnDropTask<T> {
1484    async fn join(mut self) -> Result<T, tokio::task::JoinError> {
1485        self.0.take().expect("owned task handle available").await
1486    }
1487}
1488
1489impl<T> Drop for AbortOnDropTask<T> {
1490    fn drop(&mut self) {
1491        if let Some(task) = self.0.take() {
1492            task.abort();
1493        }
1494    }
1495}
1496
1497fn bound_model_identity(
1498    snapshot: &CatalogSnapshot,
1499    requested_model_id: Option<&str>,
1500    resolved_model_id: &str,
1501) -> Result<InferenceModelIdentity, InferenceError> {
1502    let row = snapshot.model_by_exact_id(resolved_model_id).ok_or_else(|| {
1503        InferenceError::InferenceFailed(format!(
1504            "resolved model `{resolved_model_id}` was absent from the catalog snapshot bound to this request"
1505        ))
1506    })?;
1507    Ok(InferenceModelIdentity {
1508        requested_model_id: requested_model_id.map(str::to_string),
1509        resolved_model_id: row.model.id.clone(),
1510        row_digest: row.row_digest.clone(),
1511        catalog_revision: snapshot.catalog_revision.clone(),
1512    })
1513}
1514
1515fn validate_expected_catalog_revision(
1516    req: &GenerateRequest,
1517    snapshot: &CatalogSnapshot,
1518) -> Result<(), InferenceError> {
1519    if let Some(expected) = req.expected_catalog_revision.as_deref() {
1520        if expected != snapshot.catalog_revision {
1521            return Err(InferenceError::CatalogPreconditionMismatch {
1522                detail: format!(
1523                    "expected catalog revision {expected}, got {}",
1524                    snapshot.catalog_revision
1525                ),
1526            });
1527        }
1528    }
1529    Ok(())
1530}
1531
1532fn validate_expected_catalog_row(
1533    req: &GenerateRequest,
1534    snapshot: &CatalogSnapshot,
1535    resolved_model_id: &str,
1536) -> Result<(), InferenceError> {
1537    let Some(expected) = req.expected_row_digest.as_deref() else {
1538        return Ok(());
1539    };
1540    let row = snapshot
1541        .model_by_exact_id(resolved_model_id)
1542        .ok_or_else(|| InferenceError::CatalogPreconditionMismatch {
1543            detail: format!(
1544                "resolved model `{resolved_model_id}` is absent from the bound catalog snapshot"
1545            ),
1546        })?;
1547    if expected != row.row_digest {
1548        return Err(InferenceError::CatalogPreconditionMismatch {
1549            detail: format!(
1550                "expected row digest {expected} for `{resolved_model_id}`, got {}",
1551                row.row_digest
1552            ),
1553        });
1554    }
1555    Ok(())
1556}
1557
1558const EXACT_MODEL_ID_PREFIX: &str = "\0car-exact-model-id:";
1559
1560/// Mark a typed request as an exact immutable-id pin. The marker is consumed
1561/// before routing and never reaches a backend or delegated runner.
1562pub fn pin_exact_model_id(req: &mut GenerateRequest, model_id: String) -> Result<(), String> {
1563    if req.model.is_some() {
1564        return Err("`model` and `model_id` are mutually exclusive".to_string());
1565    }
1566    if model_id.trim().is_empty() {
1567        return Err("`model_id` must be a non-empty immutable id".to_string());
1568    }
1569    req.model = Some(format!("{EXACT_MODEL_ID_PREFIX}{model_id}"));
1570    req.params.strict_model = true;
1571    Ok(())
1572}
1573
1574pub fn exact_pinned_model_id(req: &GenerateRequest) -> Option<&str> {
1575    req.model
1576        .as_deref()
1577        .and_then(|model| model.strip_prefix(EXACT_MODEL_ID_PREFIX))
1578}
1579
1580/// Decide the auto-enabled thinking budget for a turn (F1). Coding turns — the
1581/// caller's EXPLICIT `IntentHint{task:Code}` (not the coarse keyword classifier,
1582/// which flags any prose containing "fix"/"bug"/"let ") — get a higher budget
1583/// than a general reasoning-heavy (Complex) turn; both require the model to
1584/// advertise extended thinking. Returns `None` when thinking should not be
1585/// auto-enabled. The budget only selects the effort level via
1586/// `reasoning_effort_from_budget` (24000 -> "high", 8000 -> "medium"); the
1587/// adaptive API decides the actual depth.
1588fn auto_thinking_budget(
1589    is_code_intent: bool,
1590    is_complex: bool,
1591    supports_thinking: bool,
1592) -> Option<usize> {
1593    if !supports_thinking {
1594        return None;
1595    }
1596    if is_code_intent {
1597        Some(24_000)
1598    } else if is_complex {
1599        Some(8_000)
1600    } else {
1601        None
1602    }
1603}
1604
1605/// Whether the caller EXPLICITLY tagged this turn as a coding task
1606/// (`IntentHint{task:Code}`) — the signal F1 keys the coding thinking budget on.
1607///
1608/// Deliberately NOT the keyword classifier's `decision.task`: the classifier
1609/// flags any prose containing "fix"/"bug"/"let " as Code, which would
1610/// over-provision high-effort thinking on incidental words, and a model-pin
1611/// clobbers `decision.task` to Generate (losing a pinned coder). Keeping this a
1612/// named pure fn (rather than an inline expression at the call site) pins that
1613/// invariant against a regression that re-keys the gate onto `decision.task`.
1614fn is_explicit_code_intent(intent: Option<&intent::IntentHint>) -> bool {
1615    intent.and_then(|h| h.task) == Some(intent::TaskHint::Code)
1616}
1617
1618/// Whether to append an installed on-device model as the remote-only last
1619/// resort. It fires only when the chain has no local model AND the request is
1620/// not a hard pin: a `strict_model` caller (the coder's `--model`, an A/B arm)
1621/// must fail loudly on a remote outage rather than silently degrade to local.
1622fn should_append_local_last_resort(chain_has_local: bool, strict_model: bool) -> bool {
1623    !chain_has_local && !strict_model
1624}
1625
1626/// The per-turn output budget for a resolved model.
1627///
1628/// When the caller left `max_tokens` at the library default we widen it to the
1629/// model's advertised output cap. That is what stops a long-horizon remote turn
1630/// from truncating a tool_use argument mid-object.
1631///
1632/// A model decoded **in-process** is the exception, and not a small one: its
1633/// token budget is a wall-clock budget. `mlx/qwen3-8b:4bit` advertises a 131072
1634/// context and no explicit output cap, so `effective_max_output()` widens 4096
1635/// to 32768 — about 24 minutes of decode at that catalog entry's own 22.4
1636/// tok/s, for one turn, and the empty result that a budget exhausted inside an
1637/// unclosed `<think>` block produces then trips the thinking-recovery retry in
1638/// [`InferenceEngine::generate_tracked`], which spends it a second time. That is
1639/// the reported hang: `car do --local` sat at ~39% CPU for 57 minutes with no
1640/// output. Such a model therefore keeps whatever budget the caller asked for.
1641/// `ModelSource::CodexCli` also keeps it: the CLI has no exact output-cap
1642/// option, so widening its best-effort instruction to a 128K catalog ceiling
1643/// would turn an ordinary default into a request for an enormous answer.
1644///
1645/// The local-model test is [`ModelSchema::decodes_in_process`], NOT `is_local` — vLLM-MLX
1646/// is local in the "runs on this machine" sense but is an HTTP server we do not
1647/// decode for, and it is precisely the local model whose tool_use JSON the
1648/// widening protects. (car#851)
1649fn resolved_max_tokens(requested: usize, schema: &ModelSchema) -> usize {
1650    if requested != crate::tasks::generate::DEFAULT_MAX_TOKENS
1651        || schema.decodes_in_process()
1652        || schema.is_codex_cli()
1653    {
1654        return requested;
1655    }
1656    schema.effective_max_output()
1657}
1658
1659/// Wall-clock ceiling on ONE in-process decode pass, in seconds.
1660///
1661/// Deliberately generous: this is a runaway backstop, not a latency target. A
1662/// decode that reaches it has stopped being useful to its caller, and until it
1663/// existed the only bound on the loop was `max_tokens` — which on a large local
1664/// model is tens of minutes of silent CPU with no way to tell a slow model from
1665/// a wedged one. (car#851)
1666///
1667/// Scope: both loops that honor it (`drive_generation_with_timeout` and
1668/// `stream_local_mlx`) are MLX, i.e. Apple Silicon. The Candle in-process path
1669/// used on every other platform is NOT bounded by this — it only gets the
1670/// `max_tokens` rule in [`resolved_max_tokens`].
1671const DEFAULT_LOCAL_DECODE_TIMEOUT_SECS: u64 = 300;
1672
1673/// `stop_reason` for a pass cut short by the ceiling above.
1674///
1675/// Deliberately not the bare `"timeout"`: a remote model's `stop_reason` is the
1676/// provider's raw `finish_reason` passed through with no allowlist, so a bare
1677/// spelling could collide with an endpoint that happens to emit it and hand a
1678/// remote caller an error naming a local wall clock. (car#851)
1679pub const LOCAL_DECODE_TIMEOUT_STOP_REASON: &str = "local_decode_timeout";
1680
1681/// How often the decode loop reports progress. Time-based, not every-N-tokens:
1682/// the defect being fixed is *silence*, and a token-count interval still goes
1683/// quiet exactly when the model slows down. (car#851)
1684///
1685/// Both users — `drive_generation_with_timeout` and `stream_local_mlx` — are
1686/// MLX-only, so carry their cfg here too or this is dead code everywhere else
1687/// and `-D warnings` fails the build on Linux.
1688#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1689const LOCAL_DECODE_HEARTBEAT_SECS: u64 = 10;
1690
1691/// Parse `CAR_LOCAL_DECODE_TIMEOUT_SECS`. `0` disables the ceiling; anything
1692/// unparseable falls back to the default rather than silently disabling it.
1693fn parse_decode_timeout(raw: Option<&str>) -> Option<std::time::Duration> {
1694    let secs = match raw {
1695        Some(v) => v
1696            .trim()
1697            .parse::<u64>()
1698            .unwrap_or(DEFAULT_LOCAL_DECODE_TIMEOUT_SECS),
1699        None => DEFAULT_LOCAL_DECODE_TIMEOUT_SECS,
1700    };
1701    (secs > 0).then(|| std::time::Duration::from_secs(secs))
1702}
1703
1704/// Has this decode pass run past its wall-clock ceiling? `None` = no ceiling.
1705///
1706/// Shared by BOTH decode loops (`drive_generation_with_timeout` and
1707/// `stream_local_mlx`) so the comparison exists once. An inverted or
1708/// off-by-one comparison here is the difference between a bounded decode and
1709/// the car#851 hang, and the streaming loop cannot be unit-tested against a
1710/// real MLX backend — so the predicate is what gets tested.
1711///
1712/// Both callers are MLX-gated, so on every other target this has no caller.
1713/// It stays compiled (rather than carrying the loops' `cfg`) so its test still
1714/// runs on Linux CI — a regression net that only works on the author's Mac is
1715/// not a regression net.
1716#[cfg_attr(
1717    not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
1718    allow(dead_code)
1719)]
1720fn deadline_exceeded(elapsed: std::time::Duration, timeout: Option<std::time::Duration>) -> bool {
1721    timeout.is_some_and(|limit| elapsed >= limit)
1722}
1723
1724/// Is another progress heartbeat due? Shared by both decode loops for the same
1725/// reason as [`deadline_exceeded`] — and because forgetting to advance
1726/// `last_heartbeat` turns the heartbeat into a per-token flood.
1727///
1728/// Ungated for the same reason as [`deadline_exceeded`].
1729#[cfg_attr(
1730    not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
1731    allow(dead_code)
1732)]
1733fn heartbeat_due(
1734    elapsed: std::time::Duration,
1735    last: std::time::Duration,
1736    interval: std::time::Duration,
1737) -> bool {
1738    elapsed.saturating_sub(last) >= interval
1739}
1740
1741/// What [`InferenceEngine::generate_tracked`] should do with the pass it just
1742/// completed. Pulled out of the async fn so the decision — the highest-blast-
1743/// radius part of the car#851 change — is unit-testable without a live model.
1744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1745enum EmptyPassAction {
1746    /// Nothing usable, and reasoning is the plausible culprit: retry once with
1747    /// thinking suppressed (the pre-existing car-releases#60 recovery).
1748    RetryWithoutThinking,
1749    /// Nothing usable and the local wall-clock ceiling is why. Retrying spends
1750    /// the same ceiling a second time and cannot end differently — that is how
1751    /// car#851 turned a 24-minute turn into a 49-minute one — so fail loudly.
1752    FailDecodeCeiling,
1753    /// Usable output, or nothing usable for a reason neither branch owns.
1754    Accept,
1755}
1756
1757/// Classify a completed pass. Order matters: the ceiling check comes FIRST,
1758/// because a ceiling stop looks exactly like a thinking truncation from here
1759/// (empty text, no tool calls — the model was still inside `<think>` when the
1760/// clock ran out) and the recovery retry would happily double the wall time.
1761///
1762/// A pass that produced *any* text or tool call is always accepted, ceiling or
1763/// not: a partial answer is worth more to the caller than an error.
1764fn classify_empty_pass(
1765    recover: bool,
1766    stop_reason: Option<&str>,
1767    text: &str,
1768    tool_calls_empty: bool,
1769) -> EmptyPassAction {
1770    if !text.trim().is_empty() || !tool_calls_empty {
1771        return EmptyPassAction::Accept;
1772    }
1773    if stop_reason == Some(LOCAL_DECODE_TIMEOUT_STOP_REASON) {
1774        return EmptyPassAction::FailDecodeCeiling;
1775    }
1776    if recover {
1777        return EmptyPassAction::RetryWithoutThinking;
1778    }
1779    EmptyPassAction::Accept
1780}
1781
1782fn local_decode_timeout() -> Option<std::time::Duration> {
1783    parse_decode_timeout(
1784        std::env::var("CAR_LOCAL_DECODE_TIMEOUT_SECS")
1785            .ok()
1786            .as_deref(),
1787    )
1788}
1789
1790impl InferenceResult {
1791    /// Canonical immutable id of the model that served this turn.
1792    ///
1793    /// Older/scripted payloads may not carry the flattened v3 identity fields;
1794    /// retain `model_used` as their compatibility fallback.
1795    pub fn served_model_id(&self) -> &str {
1796        if self.model_identity.resolved_model_id.is_empty() {
1797            &self.model_used
1798        } else {
1799            &self.model_identity.resolved_model_id
1800        }
1801    }
1802
1803    /// Returns true if the model chose to call tools instead of generating text.
1804    pub fn has_tool_calls(&self) -> bool {
1805        !self.tool_calls.is_empty()
1806    }
1807
1808    /// Returns true when the response was cut short rather than finished.
1809    /// Matches every provider spelling of an output-token cap: OpenAI chat
1810    /// `"length"`, OpenAI Responses `"max_output_tokens"`, Anthropic
1811    /// `"max_tokens"`, Google `"MAX_TOKENS"`, and the local MLX/Candle
1812    /// `"length"` — plus the local wall-clock ceiling
1813    /// (`"local_decode_timeout"`, car#851), which produces the same partial
1814    /// text for a different reason and would otherwise read as a complete
1815    /// answer. A truncated response often carries a half-written tool_use
1816    /// argument the validator will reject, so callers (e.g. car-cli run_task)
1817    /// should detect this and ask the model to retry in smaller chunks rather
1818    /// than re-emitting the oversized call.
1819    pub fn was_truncated(&self) -> bool {
1820        matches!(
1821            self.stop_reason.as_deref(),
1822            Some(
1823                "length"
1824                    | "max_tokens"
1825                    | "max_output_tokens"
1826                    | "MAX_TOKENS"
1827                    | crate::LOCAL_DECODE_TIMEOUT_STOP_REASON
1828            )
1829        )
1830    }
1831
1832    /// Append this result to a caller-owned multi-turn history.
1833    ///
1834    /// Responses continuity items belong immediately before the assistant
1835    /// message they accompanied in the provider's output sequence. Keeping the
1836    /// ordering here centralized prevents CAR's agent, coder, bench, and CLI
1837    /// loops from independently dropping or misordering opaque reasoning state.
1838    /// Personal Chat Completions and non-Responses providers leave
1839    /// `provider_output_items` empty, so their history shape is unchanged.
1840    pub fn append_assistant_history(
1841        &self,
1842        messages: &mut Vec<crate::tasks::generate::Message>,
1843        tool_calls: Vec<crate::tasks::generate::ToolCall>,
1844    ) {
1845        if !self.provider_output_items.is_empty() {
1846            messages.push(crate::tasks::generate::Message::ProviderOutputItems {
1847                protocol: crate::protocol::OPENAI_RESPONSES_PROTOCOL.to_string(),
1848                items: self.provider_output_items.clone(),
1849            });
1850        }
1851        messages.push(crate::tasks::generate::Message::Assistant {
1852            content: self.text.clone(),
1853            tool_calls,
1854            thinking: self.thinking.clone(),
1855            model_id: Some(self.served_model_id().to_string()),
1856            local_last_resort: self.local_last_resort,
1857        });
1858    }
1859}
1860
1861#[derive(Debug, Clone, Serialize)]
1862pub struct SpeechRuntimeHealth {
1863    pub root: PathBuf,
1864    pub installed: bool,
1865    pub python: PathBuf,
1866    pub stt_command: PathBuf,
1867    pub tts_command: PathBuf,
1868    pub configured_python: Option<String>,
1869    pub detected_python: Option<String>,
1870}
1871
1872#[derive(Debug, Clone, Serialize)]
1873pub struct SpeechModelHealth {
1874    pub id: String,
1875    pub name: String,
1876    pub provider: String,
1877    pub capability: ModelCapability,
1878    pub is_local: bool,
1879    pub available: bool,
1880    pub cached: bool,
1881    pub selected_by_default: bool,
1882    pub source: String,
1883}
1884
1885#[derive(Debug, Clone, Serialize)]
1886pub struct SpeechHealthReport {
1887    pub runtime: SpeechRuntimeHealth,
1888    pub local_models: Vec<SpeechModelHealth>,
1889    pub remote_models: Vec<SpeechModelHealth>,
1890    pub elevenlabs_configured: bool,
1891    pub prefer_local: bool,
1892    pub allow_remote_fallback: bool,
1893    pub preferred_local_stt: Option<String>,
1894    pub preferred_local_tts: Option<String>,
1895    pub preferred_remote_stt: Option<String>,
1896    pub preferred_remote_tts: Option<String>,
1897    pub local_stt_default: Option<String>,
1898    pub local_tts_default: Option<String>,
1899    pub remote_stt_default: Option<String>,
1900    pub remote_tts_default: Option<String>,
1901}
1902
1903#[derive(Debug, Clone, Serialize)]
1904pub struct ModelDefaultHealth {
1905    pub capability: ModelCapability,
1906    pub configured_model: String,
1907    pub available: bool,
1908    pub is_local: bool,
1909    pub provider: Option<String>,
1910}
1911
1912#[derive(Debug, Clone, Serialize)]
1913pub struct ModelProviderHealth {
1914    pub provider: String,
1915    pub configured: bool,
1916    pub local_models: usize,
1917    pub remote_models: usize,
1918    pub available_models: usize,
1919    pub capabilities: Vec<ModelCapability>,
1920}
1921
1922#[derive(Debug, Clone, Serialize)]
1923pub struct ModelCapabilityHealth {
1924    pub capability: ModelCapability,
1925    pub total_models: usize,
1926    pub available_models: usize,
1927    pub local_available_models: usize,
1928    pub remote_available_models: usize,
1929}
1930
1931#[derive(Debug, Clone, Serialize)]
1932pub struct RoutingScenarioHealth {
1933    pub name: String,
1934    pub workload: RoutingWorkload,
1935    pub task_family: String,
1936    pub has_tools: bool,
1937    pub has_vision: bool,
1938    pub prefer_local: bool,
1939    pub quality_first_cold_start: bool,
1940    pub bootstrap_min_task_observations: u64,
1941    pub bootstrap_quality_floor: f64,
1942    pub model_id: String,
1943    pub model_name: String,
1944    pub reason: String,
1945    pub strategy: RoutingStrategy,
1946}
1947
1948#[derive(Debug, Clone, Serialize)]
1949pub struct ModelBenchmarkPriorHealth {
1950    pub model_id: String,
1951    pub model_name: Option<String>,
1952    pub overall_score: f64,
1953    pub overall_latency_ms: Option<f64>,
1954    pub task_scores: std::collections::HashMap<String, f64>,
1955    pub task_latency_ms: std::collections::HashMap<String, f64>,
1956    pub source_path: PathBuf,
1957}
1958
1959#[derive(Debug, Clone, Serialize)]
1960pub struct ModelHealthReport {
1961    pub total_models: usize,
1962    pub available_models: usize,
1963    pub local_models: usize,
1964    pub remote_models: usize,
1965    pub defaults: Vec<ModelDefaultHealth>,
1966    pub providers: Vec<ModelProviderHealth>,
1967    pub capabilities: Vec<ModelCapabilityHealth>,
1968    pub routing_prefer_local: bool,
1969    pub routing_quality_first_cold_start: bool,
1970    pub routing_min_observations: u64,
1971    pub routing_bootstrap_min_task_observations: u64,
1972    pub routing_bootstrap_quality_floor: f64,
1973    pub routing_quality_weight: f64,
1974    pub routing_latency_weight: f64,
1975    pub routing_cost_weight: f64,
1976    pub routing_scenarios: Vec<RoutingScenarioHealth>,
1977    pub benchmark_priors: Vec<ModelBenchmarkPriorHealth>,
1978    pub speech: SpeechHealthReport,
1979}
1980
1981#[derive(Debug, Clone, Serialize)]
1982pub struct SpeechInstallReport {
1983    pub name: String,
1984    pub hf_repo: String,
1985    pub snapshot_path: PathBuf,
1986    pub files_downloaded: usize,
1987}
1988
1989#[derive(Debug, Clone, Serialize)]
1990pub struct SpeechSmokePathReport {
1991    pub path: String,
1992    pub tts_model: String,
1993    pub stt_model: String,
1994    pub audio_path: PathBuf,
1995    pub transcript: String,
1996}
1997
1998#[derive(Debug, Clone, Serialize, Default)]
1999pub struct SpeechSmokeReport {
2000    pub local: Option<SpeechSmokePathReport>,
2001    pub remote: Option<SpeechSmokePathReport>,
2002    pub skipped: Vec<String>,
2003}
2004
2005#[derive(Debug, Clone, Serialize, Default)]
2006pub struct SpeechPolicy {
2007    pub prefer_local: bool,
2008    pub allow_remote_fallback: bool,
2009    pub preferred_local_stt: Option<String>,
2010    pub preferred_local_tts: Option<String>,
2011    pub preferred_remote_stt: Option<String>,
2012    pub preferred_remote_tts: Option<String>,
2013}
2014
2015/// Pre-render a request for an in-process (MLX/candle) backend. Those backends
2016/// have no native `messages`/`tools` API — they complete a single prompt string
2017/// — so when a request carries multi-turn `messages` and/or `tools`, fold them
2018/// into a Qwen3 chat-format `prompt` (signatures in a `<tools>` block; the model
2019/// emits `<tool_call>` which `parse_tool_calls` recovers) and clear the
2020/// structured fields so the downstream `apply_chat_template` pass-through uses
2021/// the rendered text. A request with neither is returned unchanged (the local
2022/// generate path stays byte-for-byte identical for plain text completion).
2023fn render_for_local_backend(mut req: GenerateRequest) -> GenerateRequest {
2024    let has_msgs = req.messages.as_ref().is_some_and(|m| !m.is_empty());
2025    let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
2026    if has_msgs || has_tools {
2027        req.prompt = tasks::generate::render_chat_prompt(&req);
2028        req.messages = None;
2029        req.tools = None;
2030    }
2031    req
2032}
2033
2034#[cfg(test)]
2035static UNRETIRED_RELEASE_WARNING_COUNT: std::sync::atomic::AtomicUsize =
2036    std::sync::atomic::AtomicUsize::new(0);
2037
2038/// The main inference engine. Thread-safe, lazily loads models.
2039///
2040/// Now includes the unified registry, adaptive router, and outcome tracker
2041/// for schema-driven model selection with learned performance profiles.
2042
2043/// Build a [`TokenUsage`] for a FoundationModels turn.
2044///
2045/// The backend reports no usage of its own, so this asks the framework's
2046/// tokenizer after the fact (see
2047/// [`backend::foundation_models::count_tokens`]). `None` below macOS 26.4 and
2048/// wherever the shim was not built, which keeps "unknown" distinguishable from
2049/// a genuine zero — a captured tool-call turn legitimately produces no
2050/// completion text.
2051#[cfg(any(
2052    all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2053    all(target_os = "ios", target_arch = "aarch64")
2054))]
2055fn foundation_models_usage(
2056    instructions: Option<&str>,
2057    prompt: &str,
2058    completion: &str,
2059    context_window: u64,
2060) -> Option<crate::TokenUsage> {
2061    let (prompt_tokens, completion_tokens) =
2062        crate::backend::foundation_models::count_tokens(instructions, prompt, completion)?;
2063    Some(crate::TokenUsage {
2064        prompt_tokens,
2065        completion_tokens,
2066        total_tokens: prompt_tokens + completion_tokens,
2067        context_window,
2068        // On-device inference has no remote prompt cache and costs nothing.
2069        ..Default::default()
2070    })
2071}
2072
2073pub struct InferenceEngine {
2074    pub config: InferenceConfig,
2075    /// Unified model registry (local + remote).
2076    pub unified_registry: UnifiedRegistry,
2077    /// Adaptive router with three-phase selection.
2078    pub adaptive_router: AdaptiveRouter,
2079    /// Outcome tracker for learning from results.
2080    pub outcome_tracker: Arc<RwLock<OutcomeTracker>>,
2081    /// Last time `auto_save_outcomes` flushed the tracker (debounce gate).
2082    /// `None` until the first flush. Paired with the tracker's dirty flag
2083    /// so we persist at most once per `OUTCOME_FLUSH_INTERVAL` and only
2084    /// when a profile actually changed — instead of rewriting the whole
2085    /// file after every inference call.
2086    last_outcome_flush: Arc<std::sync::Mutex<Option<Instant>>>,
2087    /// Serializes all mutations of the outcome-ledger file so a concurrent
2088    /// append (from a per-call flush) and a prune (read+rename) can't
2089    /// interleave and drop a receipt — the ledger's whole value is that no
2090    /// receipt is silently lost.
2091    ledger_io_lock: Arc<tokio::sync::Mutex<()>>,
2092    /// Optional spend limits (I4). When `per_request_usd` is set, the
2093    /// streaming path arms a [`routing_ext::MidStreamSpendGuard`] so a
2094    /// runaway long output is cancelled mid-stream instead of billed to
2095    /// completion. Rust-embedder API (no FFI surface by design — see the
2096    /// I4 handoff note); set via [`InferenceEngine::set_spend_limits`].
2097    spend_limits: Arc<std::sync::RwLock<Option<SpendLimits>>>,
2098    /// In-memory cache of lane defaults (Phase D1), so the hot routing
2099    /// path consults the user's pinned models without a disk read per
2100    /// inference. Loaded at construction; kept in sync by
2101    /// `set_lane_default`/`clear_lane_default` (which also persist).
2102    lane_defaults_cache: Arc<std::sync::RwLock<crate::lane_defaults::LaneDefaults>>,
2103    /// Serializes lane-mutating concierge operations (apply / rollback /
2104    /// canary revert) so the action-ledger read-modify-write is atomic —
2105    /// the canary watcher and a user `apply` can't interleave and revert a
2106    /// switch the user just made. The model download in `apply` stays
2107    /// OUTSIDE this lock (no blocking the canary tick on a multi-GB pull).
2108    concierge_action_lock: Arc<tokio::sync::Mutex<()>>,
2109    /// Monotonic counter for concierge action `seq` (the canary's anchor
2110    /// identity). Initialized past the max seq already in the ledger so it
2111    /// stays monotonic across restarts.
2112    concierge_action_seq: Arc<std::sync::atomic::AtomicU64>,
2113    /// HTTP client for remote API models.
2114    remote_backend: RemoteBackend,
2115    /// Durable ownership/tombstone records plus machine-shared activity locks
2116    /// for local model artifacts.
2117    model_management: model_management::ModelManagementStore,
2118    /// Shared admission service used by every CAR-owned local allocation.
2119    local_admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2120    /// Keeps the state-root-scoped runtime composition alive. Engines pointed
2121    /// at the same CAR state root share every resident backend/cache/process;
2122    /// this prevents a second engine from loading peer-discounted duplicate
2123    /// weights or clearing another engine's residency accounting.
2124    _runtime_scope: Arc<ScopedInferenceRuntime>,
2125    resource_policy_generation: Arc<std::sync::atomic::AtomicU64>,
2126    /// Startup load source/warning paired with the active admission policy.
2127    /// The policy itself is refreshed from `local_admission` by the accessor,
2128    /// so independently constructed engines sharing one coordinator agree.
2129    resource_policy_evidence: Arc<std::sync::RwLock<resource_policy::ResourcePolicyLoadEvidence>>,
2130    /// Mutable cache ceiling paired with `local_admission` so a policy change
2131    /// affects both future reservations and retained idle weights.
2132    model_budget: Arc<backend_cache::SharedModelBudget>,
2133    /// Native MLX text-gen / embedding backends keyed by model id.
2134    /// Same cache shape as `flux_cache` / `ltx_cache` / `kokoro_cache`:
2135    /// per-entry `Arc<Mutex<MlxBackend>>` so concurrent calls for the
2136    /// same model serialize, while calls for different models proceed
2137    /// in parallel. Also bounded by `CAR_INFERENCE_MODEL_CACHE_MB`.
2138    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2139    mlx_backends: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
2140    /// Polymorphic cache of NEW-architecture in-process MLX backends (Gemma 4,
2141    /// …) dispatched via `backend::local::local_backend_for` and driven by the
2142    /// shared `drive_generation` loop. Qwen3 keeps the dedicated `mlx_backends`
2143    /// cache above (which also backs streaming / tokenize / embeddings); each
2144    /// model lives in exactly one cache by architecture, so there is no
2145    /// double-load.
2146    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2147    local_backends:
2148        Arc<backend_cache::BackendCache<Box<dyn backend::local::LocalInferenceBackend>>>,
2149    /// LRU-evicting, mutex-serialized cache of loaded Flux image backends.
2150    /// Avoids reloading the 4–5 GB model on every generate_image call,
2151    /// and serializes concurrent calls onto the same backend (MLX ops
2152    /// are not `Sync`).
2153    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2154    flux_cache: Arc<backend_cache::BackendCache<backend::mlx_flux::FluxBackend>>,
2155    /// Same for LTX video (~9 GB: transformer + Gemma 3 12B + VAE + vocoder).
2156    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2157    ltx_cache: Arc<backend_cache::BackendCache<backend::mlx_ltx::LtxBackend>>,
2158    /// Same for Kokoro TTS (~160 MB). Small but reloading per-utterance
2159    /// added ~1 s of latency to every `synth` call.
2160    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2161    kokoro_cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2162    // Legacy fields kept for backward compatibility
2163    pub registry: models::ModelRegistry,
2164    pub router: ModelRouter,
2165    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2166    backend: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
2167    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2168    embedding_backend: Arc<RwLock<Option<EmbeddingBackend>>>,
2169    speech_runtime: Arc<Mutex<Option<SpeechRuntime>>>,
2170    speech_policy: SpeechPolicy,
2171    /// On-demand supervised `vllm-mlx` servers for `vllm-mlx/*` models. Lazy-
2172    /// started on dispatch, idle-evicted alongside the in-process backends, so a
2173    /// server-backed model is indistinguishable from an in-process one.
2174    vllm_pool: Arc<vllm_pool::VllmServerPool>,
2175}
2176
2177enum SpeechCandidateAdmission {
2178    Proceed(Option<resource_policy::LocalLoadReservation>),
2179    SkipBlocked(InferenceError),
2180    FailBlocked(InferenceError),
2181}
2182
2183struct ScopedInferenceRuntime {
2184    model_budget: Arc<backend_cache::SharedModelBudget>,
2185    resource_policy_generation: Arc<std::sync::atomic::AtomicU64>,
2186    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2187    mlx_backends: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
2188    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2189    local_backends:
2190        Arc<backend_cache::BackendCache<Box<dyn backend::local::LocalInferenceBackend>>>,
2191    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2192    flux_cache: Arc<backend_cache::BackendCache<backend::mlx_flux::FluxBackend>>,
2193    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2194    ltx_cache: Arc<backend_cache::BackendCache<backend::mlx_ltx::LtxBackend>>,
2195    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2196    kokoro_cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2197    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2198    backend: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
2199    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2200    embedding_backend: Arc<RwLock<Option<EmbeddingBackend>>>,
2201    speech_runtime: Arc<Mutex<Option<SpeechRuntime>>>,
2202    vllm_pool: Arc<vllm_pool::VllmServerPool>,
2203    #[cfg(test)]
2204    load_probe: Arc<backend_cache::BackendCache<()>>,
2205}
2206
2207fn scoped_inference_runtime_registry() -> &'static std::sync::Mutex<
2208    std::collections::HashMap<PathBuf, std::sync::Weak<ScopedInferenceRuntime>>,
2209> {
2210    static REGISTRY: std::sync::OnceLock<
2211        std::sync::Mutex<
2212            std::collections::HashMap<PathBuf, std::sync::Weak<ScopedInferenceRuntime>>,
2213        >,
2214    > = std::sync::OnceLock::new();
2215    REGISTRY.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
2216}
2217
2218fn configured_cache_budget_mb(default_mb: u64) -> u64 {
2219    std::env::var("CAR_INFERENCE_MODEL_CACHE_MB")
2220        .ok()
2221        .and_then(|value| value.parse::<u64>().ok())
2222        .unwrap_or(default_mb)
2223}
2224
2225fn scoped_inference_runtime(
2226    state_root: &Path,
2227    configured_ceiling_mb: u64,
2228    admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2229) -> Arc<ScopedInferenceRuntime> {
2230    let state_root = resource_policy::normalized_state_root_key(state_root);
2231    let mut registry = scoped_inference_runtime_registry()
2232        .lock()
2233        .unwrap_or_else(std::sync::PoisonError::into_inner);
2234    registry.retain(|_, runtime| runtime.strong_count() > 0);
2235    if let Some(runtime) = registry.get(&state_root).and_then(std::sync::Weak::upgrade) {
2236        runtime.model_budget.set_budget_bytes(
2237            configured_cache_budget_mb(configured_ceiling_mb).saturating_mul(1024 * 1024),
2238        );
2239        return runtime;
2240    }
2241
2242    let model_budget = backend_cache::SharedModelBudget::from_env_or(configured_ceiling_mb);
2243    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2244    let cache_idle = backend_cache::idle_ttl_from_env();
2245    let runtime = Arc::new(ScopedInferenceRuntime {
2246        model_budget: model_budget.clone(),
2247        resource_policy_generation: Arc::new(std::sync::atomic::AtomicU64::new(1)),
2248        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2249        mlx_backends: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2250            model_budget.clone(),
2251            cache_idle,
2252            Some(admission.clone()),
2253        )),
2254        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2255        local_backends: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2256            model_budget.clone(),
2257            cache_idle,
2258            Some(admission.clone()),
2259        )),
2260        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2261        flux_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2262            model_budget.clone(),
2263            cache_idle,
2264            Some(admission.clone()),
2265        )),
2266        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2267        ltx_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2268            model_budget.clone(),
2269            cache_idle,
2270            Some(admission.clone()),
2271        )),
2272        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2273        kokoro_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2274            model_budget,
2275            cache_idle,
2276            Some(admission.clone()),
2277        )),
2278        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2279        backend: Arc::new(RwLock::new(std::collections::HashMap::new())),
2280        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2281        embedding_backend: Arc::new(RwLock::new(None)),
2282        speech_runtime: Arc::new(Mutex::new(None)),
2283        // Records live under the state root so every CAR process on it (the
2284        // daemon, a `car` CLI run, an embedding host) reclaims what an exited
2285        // one left behind.
2286        vllm_pool: Arc::new(
2287            vllm_pool::VllmServerPool::with_admission(
2288                std::time::Duration::from_secs(
2289                    std::env::var("CAR_VLLM_IDLE_SECS")
2290                        .ok()
2291                        .and_then(|value| value.parse().ok())
2292                        .unwrap_or(300),
2293                ),
2294                admission,
2295            )
2296            .with_process_records(state_root.join("run").join("vllm-mlx")),
2297        ),
2298        #[cfg(test)]
2299        load_probe: Arc::new(backend_cache::BackendCache::new(1024)),
2300    });
2301    registry.insert(state_root, Arc::downgrade(&runtime));
2302    runtime
2303}
2304
2305/// A root-scoped Kokoro cache lease shared by the inference engine and
2306/// car-voice. The runtime is held only for as long as an actual voice owner
2307/// exists; merely importing car-voice cannot pin the default CAR runtime.
2308#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2309pub struct ScopedKokoroBackendCache {
2310    cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2311    _runtime: Arc<ScopedInferenceRuntime>,
2312    admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2313}
2314
2315#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2316impl ScopedKokoroBackendCache {
2317    pub fn cache(&self) -> &Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>> {
2318        &self.cache
2319    }
2320
2321    pub fn admission(&self) -> &Arc<resource_policy::LocalAdmissionCoordinator> {
2322        &self.admission
2323    }
2324}
2325
2326#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2327pub fn scoped_kokoro_backend_cache(state_root: &Path) -> ScopedKokoroBackendCache {
2328    let state_root = resource_policy::normalized_state_root_key(state_root);
2329    let policy = resource_policy::FileResourcePolicyRepository::new(state_root.clone())
2330        .load()
2331        .unwrap_or_else(|_| resource_policy::ResourcePolicy::everyday());
2332    let hardware = HardwareInfo::detect();
2333    let admission = resource_policy::scoped_local_admission(&state_root, policy, hardware.clone());
2334    let ceiling_mb = admission
2335        .policy()
2336        .effective_budget(hardware.total_ram_mb)
2337        .configured_model_ceiling_mb;
2338    let runtime = scoped_inference_runtime(&state_root, ceiling_mb, admission.clone());
2339    ScopedKokoroBackendCache {
2340        cache: runtime.kokoro_cache.clone(),
2341        _runtime: runtime,
2342        admission,
2343    }
2344}
2345
2346impl InferenceEngine {
2347    fn requires_local_admission(schema: &ModelSchema) -> bool {
2348        schema.is_car_managed_vllm_mlx() || Self::supports_worker_offload(schema)
2349    }
2350
2351    /// Managed vLLM owns a wider startup transaction than ordinary local
2352    /// loaders: its reservation must be created only after the per-model
2353    /// dispatch gate is held. Reserving in the generic outer path lets a
2354    /// concurrent request observe the first startup's provisional process
2355    /// charge and fail before it can join the singleflight.
2356    fn reserve_in_outer_dispatch(schema: &ModelSchema) -> bool {
2357        Self::requires_local_admission(schema) && !schema.is_car_managed_vllm_mlx()
2358    }
2359
2360    fn supports_worker_offload(schema: &ModelSchema) -> bool {
2361        matches!(
2362            schema.source,
2363            ModelSource::Local { .. } | ModelSource::Mlx { .. }
2364        )
2365    }
2366
2367    /// Actual post-dispatch retention observed by worker/process owners.
2368    /// Success alone is insufficient: zero-cache loads remain transient.
2369    pub fn local_model_retention(&self, model_id: &str) -> backend_cache::BackendRetention {
2370        if self.local_admission.is_resident(model_id) {
2371            backend_cache::BackendRetention::Resident
2372        } else {
2373            backend_cache::BackendRetention::Transient
2374        }
2375    }
2376    /// Evaluate one local model without downloading or loading it.
2377    pub fn local_model_preflight(
2378        &self,
2379        model_id: &str,
2380        context_tokens: usize,
2381    ) -> Result<resource_policy::LocalLoadPreflight, InferenceError> {
2382        let schema = self
2383            .unified_registry
2384            .get(model_id)
2385            .or_else(|| self.unified_registry.find_by_name(model_id))
2386            .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
2387        self.ensure_model_enabled(&schema.id)?;
2388        Ok(self.local_admission.preflight(schema, context_tokens))
2389    }
2390
2391    /// The policy currently enforced by local-model admission, plus the load
2392    /// source/warning captured when this engine initialized it.
2393    ///
2394    /// Read-side model surfaces use this accessor rather than reopening the
2395    /// policy file, so a policy applied to the running engine is one atomic
2396    /// source of truth for preflight, fit annotations, and recommendations.
2397    pub fn active_local_resource_policy(&self) -> resource_policy::ResourcePolicyLoadEvidence {
2398        let mut evidence = self
2399            .resource_policy_evidence
2400            .read()
2401            .unwrap_or_else(|poisoned| poisoned.into_inner())
2402            .clone();
2403        evidence.policy = self.local_admission.policy();
2404        evidence
2405    }
2406
2407    /// Update the in-memory admission/cache ceiling after persistence succeeds.
2408    /// Idle entries are reclaimed by each cache's next sweep/access; active
2409    /// inference is never killed by a policy decrease.
2410    pub fn apply_local_resource_policy(&self, policy: resource_policy::ResourcePolicy) {
2411        let ceiling_mb = policy
2412            .effective_budget(HardwareInfo::detect().total_ram_mb)
2413            .configured_model_ceiling_mb;
2414        self.local_admission.set_policy(policy.clone());
2415        *self
2416            .resource_policy_evidence
2417            .write()
2418            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
2419            resource_policy::ResourcePolicyLoadEvidence {
2420                policy,
2421                source: resource_policy::ResourcePolicyLoadSource::Loaded,
2422                warning: None,
2423            };
2424        let cache_ceiling_mb = configured_cache_budget_mb(ceiling_mb);
2425        self.model_budget
2426            .set_budget_bytes(cache_ceiling_mb.saturating_mul(1024 * 1024));
2427        let generation = self
2428            .resource_policy_generation
2429            .fetch_add(1, std::sync::atomic::Ordering::AcqRel)
2430            .saturating_add(1);
2431        if let Some(offload) = crate::offload::current_local_offload() {
2432            offload.refresh_resource_policy(generation);
2433        }
2434        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2435        {
2436            self.mlx_backends.enforce_budget();
2437            self.local_backends.enforce_budget();
2438            self.flux_cache.enforce_budget();
2439            self.ltx_cache.enforce_budget();
2440            self.kokoro_cache.enforce_budget();
2441        }
2442    }
2443
2444    pub fn begin_local_model_maintenance(
2445        &self,
2446        model_id: &str,
2447    ) -> Result<resource_policy::LocalModelMaintenanceGuard, resource_policy::ModelMaintenanceError>
2448    {
2449        self.local_admission.begin_model_maintenance(model_id)
2450    }
2451
2452    pub async fn prepare_local_model_removal(
2453        &self,
2454        model_id: &str,
2455    ) -> Result<resource_policy::LocalModelMaintenanceGuard, resource_policy::ModelMaintenanceError>
2456    {
2457        let maintenance = self.local_admission.begin_model_maintenance(model_id)?;
2458        if let Some(offload) = crate::offload::current_local_offload() {
2459            if offload
2460                .resident_models()
2461                .await
2462                .iter()
2463                .any(|resident| resident == model_id)
2464            {
2465                let acknowledged = offload.release_model(model_id).await.map_err(|error| {
2466                    resource_policy::ModelMaintenanceError::ReleaseFailed(error.to_string())
2467                })?;
2468                if !acknowledged
2469                    || offload
2470                        .resident_models()
2471                        .await
2472                        .iter()
2473                        .any(|resident| resident == model_id)
2474                {
2475                    return Err(
2476                        resource_policy::ModelMaintenanceError::WorkerReleaseUnacknowledged(
2477                            model_id.to_string(),
2478                        ),
2479                    );
2480                }
2481            }
2482        }
2483        if self
2484            .vllm_pool
2485            .release_model_if_present(model_id)
2486            .await
2487            .is_err()
2488        {
2489            return Err(
2490                resource_policy::ModelMaintenanceError::ProcessReleaseUnacknowledged(
2491                    model_id.to_string(),
2492                ),
2493            );
2494        }
2495        if !self.evict_local_model_if_idle(model_id) {
2496            return Err(resource_policy::ModelMaintenanceError::CacheReleaseBlocked(
2497                model_id.to_string(),
2498            ));
2499        }
2500        let allocation_ids = self.local_admission.resident_allocation_ids(model_id);
2501        if !allocation_ids.is_empty() {
2502            return Err(resource_policy::ModelMaintenanceError::ResidualResidency {
2503                model_id: model_id.to_string(),
2504                allocation_ids,
2505            });
2506        }
2507        Ok(maintenance)
2508    }
2509
2510    /// Targeted cache eviction for safe model removal. Callers must hold the
2511    /// per-model maintenance guard while invoking this and checking any
2512    /// worker/cross-process leases.
2513    pub fn evict_local_model_if_idle(&self, model_id: &str) -> bool {
2514        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2515        {
2516            let mut allocation_ids = self.local_admission.resident_allocation_ids(model_id);
2517            if !allocation_ids.iter().any(|id| id == model_id) {
2518                allocation_ids.push(model_id.to_string());
2519            }
2520            allocation_ids.into_iter().all(|allocation_id| {
2521                [
2522                    self.mlx_backends.evict_if_idle(&allocation_id),
2523                    self.local_backends.evict_if_idle(&allocation_id),
2524                    self.flux_cache.evict_if_idle(&allocation_id),
2525                    self.ltx_cache.evict_if_idle(&allocation_id),
2526                    self.kokoro_cache.evict_if_idle(&allocation_id),
2527                ]
2528                .into_iter()
2529                .all(|evicted| evicted)
2530            })
2531        }
2532        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2533        {
2534            let _ = model_id;
2535            let backend_released = self.backend.try_write().is_ok_and(|mut backends| {
2536                if backends.remove(model_id).is_some() {
2537                    self.local_admission.mark_evicted(model_id);
2538                }
2539                true
2540            });
2541            // The embedding slot is still single-purpose. Fail closed when it
2542            // is populated or actively locked rather than unloading an
2543            // unrelated embedding model.
2544            backend_released
2545                && self
2546                    .embedding_backend
2547                    .try_write()
2548                    .is_ok_and(|slot| slot.is_none())
2549        }
2550    }
2551
2552    fn reserve_local_request(
2553        &self,
2554        schema: &ModelSchema,
2555        context_tokens: usize,
2556    ) -> Result<resource_policy::LocalLoadReservation, InferenceError> {
2557        self.ensure_model_enabled(&schema.id)?;
2558        let activity_lease = self.model_management.acquire_lease(&schema.id)?;
2559        let mut reservation = self
2560            .local_admission
2561            .reserve(schema, context_tokens)
2562            .map_err(InferenceError::from)?;
2563        reservation.attach_activity_lease(activity_lease);
2564        Ok(reservation)
2565    }
2566
2567    /// A resident worker can itself be the reason live memory is low. Release
2568    /// that exact CAR-owned process once, require its exact admission allocation
2569    /// to be retired, and retry admission once before surfacing the original
2570    /// refusal.
2571    async fn reserve_local_request_with_worker_retry(
2572        &self,
2573        schema: &ModelSchema,
2574        context_tokens: usize,
2575    ) -> Result<resource_policy::LocalLoadReservation, InferenceError> {
2576        let original_error = match self.reserve_local_request(schema, context_tokens) {
2577            Ok(reservation) => return Ok(reservation),
2578            Err(error) => error,
2579        };
2580        let first_verdict = match &original_error {
2581            InferenceError::LocalResourceBlocked { preflight, .. } => preflight.verdict.clone(),
2582            _ => return Err(original_error),
2583        };
2584        if first_verdict != resource_policy::LocalLoadVerdict::InsufficientLiveMemory {
2585            return Err(original_error);
2586        }
2587
2588        let Some(offload) = crate::offload::current_local_offload() else {
2589            tracing::info!(
2590                model = %schema.id,
2591                first_verdict = ?first_verdict,
2592                release_happened = false,
2593                second_verdict = ?Option::<resource_policy::LocalLoadVerdict>::None,
2594                "local admission worker release-and-retry decision"
2595            );
2596            return Err(original_error);
2597        };
2598        if !offload
2599            .resident_models()
2600            .await
2601            .iter()
2602            .any(|model_id| model_id == &schema.id)
2603        {
2604            tracing::info!(
2605                model = %schema.id,
2606                first_verdict = ?first_verdict,
2607                release_happened = false,
2608                second_verdict = ?Option::<resource_policy::LocalLoadVerdict>::None,
2609                "local admission worker release-and-retry decision"
2610            );
2611            return Err(original_error);
2612        }
2613        let Some(scoped_allocation_id) = offload.resident_allocation_id(&schema.id) else {
2614            tracing::debug!(
2615                model = %schema.id,
2616                "release-and-retry skipped because the offload exposes no allocation id"
2617            );
2618            return Err(original_error);
2619        };
2620        match offload.release_model(&schema.id).await {
2621            Ok(true) => {
2622                // Verify the public offload contract before trusting its ACK.
2623                // Never retire here: a late caller-side retirement can erase a
2624                // replacement worker that reused the same allocation id.
2625                let offload_still_resident = offload
2626                    .resident_models()
2627                    .await
2628                    .iter()
2629                    .any(|model_id| model_id == &schema.id);
2630                // This allocation-set query includes both resident and
2631                // teardown-pending IDs, unlike the logical-model is_resident view.
2632                let allocation_still_charged = self
2633                    .local_admission
2634                    .resident_allocation_ids(&schema.id)
2635                    .iter()
2636                    .any(|allocation_id| allocation_id == &scoped_allocation_id);
2637                if offload_still_resident || allocation_still_charged {
2638                    // This can mean a dishonest release ACK or an honest fast
2639                    // replacement under the same ID. From outside the worker
2640                    // those are indistinguishable, so deliberately fail closed.
2641                    tracing::warn!(
2642                        model = %schema.id,
2643                        allocation = %scoped_allocation_id,
2644                        offload_still_resident,
2645                        allocation_still_charged,
2646                        "release acknowledged but allocation {} still charged/pending",
2647                        scoped_allocation_id
2648                    );
2649                    #[cfg(test)]
2650                    UNRETIRED_RELEASE_WARNING_COUNT
2651                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2652                    return Err(original_error);
2653                }
2654                let second = self.reserve_local_request(schema, context_tokens);
2655                let second_verdict = match &second {
2656                    Ok(_) => Some("admitted".to_string()),
2657                    Err(InferenceError::LocalResourceBlocked { preflight, .. }) => {
2658                        Some(format!("{:?}", preflight.verdict))
2659                    }
2660                    Err(_) => None,
2661                };
2662                tracing::info!(
2663                    model = %schema.id,
2664                    first_verdict = ?first_verdict,
2665                    release_happened = true,
2666                    second_verdict = ?second_verdict,
2667                    "local admission worker release-and-retry decision"
2668                );
2669                second
2670            }
2671            Ok(false) | Err(_) => {
2672                tracing::info!(
2673                    model = %schema.id,
2674                    first_verdict = ?first_verdict,
2675                    release_happened = false,
2676                    second_verdict = ?Option::<resource_policy::LocalLoadVerdict>::None,
2677                    "local admission worker release-and-retry decision"
2678                );
2679                Err(original_error)
2680            }
2681        }
2682    }
2683
2684    /// Recheck an installed allocation used only for the duration of one
2685    /// subprocess call. This deliberately never publishes resident weights;
2686    /// dropping the reservation after the subprocess exits releases the full
2687    /// measured allocation.
2688    #[cfg(any(
2689        test,
2690        all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))
2691    ))]
2692    fn reconcile_transient_local_allocation(
2693        reservation: &mut resource_policy::LocalLoadReservation,
2694        measured_weights_bytes: u64,
2695    ) -> Result<(), InferenceError> {
2696        reservation
2697            .reconcile_measured_weights(measured_weights_bytes)
2698            .map(|_| ())
2699            .map_err(InferenceError::from)
2700    }
2701
2702    fn prepare_worker_admission(
2703        &self,
2704        schema: &ModelSchema,
2705        reservation: &mut resource_policy::LocalLoadReservation,
2706    ) -> Result<crate::offload::LocalWorkerAdmission, InferenceError> {
2707        let installed = self.config.models_dir.join(&schema.name);
2708        let installed_weights_bytes = backend_cache::estimate_model_size(&installed);
2709        // On a fresh machine the parent may dispatch before the worker has
2710        // downloaded the managed artifact. Zero is "not measured", not proof
2711        // that the model has no weights. Always re-run atomic admission after
2712        // binding the exact worker generation, using the larger of installed
2713        // evidence and the conservative catalog estimate; this prevents a new
2714        // worker from peer-discounting an older process still exiting.
2715        let measured_weights_bytes =
2716            installed_weights_bytes.max(reservation.reconciled_weights_bytes());
2717        reservation
2718            .reconcile_measured_weights(measured_weights_bytes)
2719            .map_err(InferenceError::from)?;
2720        Ok(crate::offload::LocalWorkerAdmission {
2721            policy: self.local_admission.policy(),
2722            policy_generation: self
2723                .resource_policy_generation
2724                .load(std::sync::atomic::Ordering::Acquire),
2725            state_root: resource_policy::normalized_state_root_key(&self.config.state_root),
2726            measured_weights_bytes,
2727        })
2728    }
2729
2730    async fn reconcile_worker_residency(
2731        offload: &dyn crate::offload::LocalGenerationOffload,
2732        expected_model_id: &str,
2733        residency: &crate::offload::LocalWorkerResidency,
2734        retention: backend_cache::BackendRetention,
2735        reservation: &mut resource_policy::LocalLoadReservation,
2736    ) -> Result<(), InferenceError> {
2737        if residency.model_id != expected_model_id {
2738            if retention == backend_cache::BackendRetention::Resident {
2739                let released = offload
2740                    .release_model(&residency.model_id)
2741                    .await
2742                    .unwrap_or(false);
2743                if !released {
2744                    let allocation_id = offload
2745                        .resident_allocation_id(&residency.model_id)
2746                        .unwrap_or_else(|| {
2747                            resource_policy::worker_process_allocation_id(&residency.model_id)
2748                        });
2749                    reservation.publish_resident_weights_as(
2750                        &allocation_id,
2751                        residency.measured_weights_bytes,
2752                    );
2753                }
2754            }
2755            return Err(InferenceError::InferenceFailed(format!(
2756                "local worker acknowledged model '{}' for requested '{}'",
2757                residency.model_id, expected_model_id
2758            )));
2759        }
2760        if retention == backend_cache::BackendRetention::Resident {
2761            let allocation_id = offload
2762                .resident_allocation_id(expected_model_id)
2763                .unwrap_or_else(|| {
2764                    resource_policy::worker_process_allocation_id(expected_model_id)
2765                });
2766            reservation
2767                .publish_resident_weights_as(&allocation_id, residency.measured_weights_bytes);
2768        }
2769        Ok(())
2770    }
2771
2772    fn admit_speech_candidate(
2773        &self,
2774        schema: &ModelSchema,
2775        explicit: bool,
2776    ) -> SpeechCandidateAdmission {
2777        if !schema.is_local()
2778            || matches!(
2779                schema.source,
2780                ModelSource::WindowsSpeech {} | ModelSource::AppleFoundationModels { .. }
2781            )
2782        {
2783            return SpeechCandidateAdmission::Proceed(None);
2784        }
2785        match self.reserve_local_request(schema, 0) {
2786            Ok(reservation) => SpeechCandidateAdmission::Proceed(Some(reservation)),
2787            Err(error) if explicit => SpeechCandidateAdmission::FailBlocked(error),
2788            Err(error) => SpeechCandidateAdmission::SkipBlocked(error),
2789        }
2790    }
2791
2792    fn hold_local_reservation_for_stream(
2793        mut source: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
2794        reservation: resource_policy::LocalLoadReservation,
2795    ) -> tokio::sync::mpsc::Receiver<stream::StreamEvent> {
2796        let (tx, rx) = tokio::sync::mpsc::channel(64);
2797        tokio::spawn(async move {
2798            let _reservation = reservation;
2799            while let Some(event) = source.recv().await {
2800                if tx.send(event).await.is_err() {
2801                    break;
2802                }
2803            }
2804        });
2805        rx
2806    }
2807
2808    fn hold_optional_reservation_for_stream(
2809        source: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
2810        reservation: Option<resource_policy::LocalLoadReservation>,
2811    ) -> tokio::sync::mpsc::Receiver<stream::StreamEvent> {
2812        match reservation {
2813            Some(reservation) => Self::hold_local_reservation_for_stream(source, reservation),
2814            None => source,
2815        }
2816    }
2817
2818    /// Install (or clear) spend limits (I4). `per_request_usd` also arms
2819    /// the mid-stream guard on streaming calls: the stream is cancelled
2820    /// with a terminal `StopReason("spend_limit: ...")` the moment the
2821    /// estimated running cost (prompt + streamed output) crosses the
2822    /// budget.
2823    pub fn set_spend_limits(&self, limits: Option<SpendLimits>) {
2824        *self.spend_limits.write().unwrap() = limits;
2825    }
2826
2827    fn preferred_model_for_capability(&self, capability: ModelCapability) -> Option<&str> {
2828        match capability {
2829            ModelCapability::Generate => self.config.preferred_generation_model.as_deref(),
2830            ModelCapability::Embed => self.config.preferred_embedding_model.as_deref(),
2831            ModelCapability::Classify => self.config.preferred_classification_model.as_deref(),
2832            _ => None,
2833        }
2834    }
2835
2836    /// True when the request carries a NON-EMPTY tool catalog.
2837    /// `tools: Some(vec![])` is "no tools": it must not require the
2838    /// ToolUse capability in routing, and it must not push the
2839    /// FoundationModels dispatch onto the tool path (which would drop
2840    /// a `response_format` JsonSchema constraint for zero tools).
2841    fn request_has_tools(req: &GenerateRequest) -> bool {
2842        req.tools.as_ref().is_some_and(|t| !t.is_empty())
2843    }
2844
2845    fn request_needs_vision(req: &GenerateRequest) -> bool {
2846        req.images.as_ref().is_some_and(|images| !images.is_empty())
2847            || req.messages.as_ref().is_some_and(|messages| {
2848                messages
2849                    .iter()
2850                    .any(|msg| matches!(msg, Message::UserMultimodal { .. }))
2851            })
2852    }
2853
2854    /// True when any content block in the request carries video data.
2855    /// Backends without a video-tokenization path use this to reject
2856    /// the request up front with [`InferenceError::UnsupportedMode`]
2857    /// rather than silently dropping the content.
2858    #[allow(dead_code)] // conditionally compiled — used only on the FoundationModels (macOS) dispatch branch
2859    fn request_has_video(req: &GenerateRequest) -> bool {
2860        let images_have_video = req
2861            .images
2862            .as_ref()
2863            .is_some_and(|blocks| blocks.iter().any(ContentBlock::is_video));
2864        let messages_have_video = req.messages.as_ref().is_some_and(|messages| {
2865            messages.iter().any(|msg| match msg {
2866                Message::UserMultimodal { content } => content.iter().any(ContentBlock::is_video),
2867                _ => false,
2868            })
2869        });
2870        images_have_video || messages_have_video
2871    }
2872
2873    /// True when any content block in the request carries audio data.
2874    /// Same role as [`request_has_video`] but for the audio path
2875    /// (Gemma 4 small variants, Gemini).
2876    #[allow(dead_code)] // conditionally compiled — used only on the FoundationModels (macOS) dispatch branch
2877    fn request_has_audio(req: &GenerateRequest) -> bool {
2878        let images_have_audio = req
2879            .images
2880            .as_ref()
2881            .is_some_and(|blocks| blocks.iter().any(ContentBlock::is_audio));
2882        let messages_have_audio = req.messages.as_ref().is_some_and(|messages| {
2883            messages.iter().any(|msg| match msg {
2884                Message::UserMultimodal { content } => content.iter().any(ContentBlock::is_audio),
2885                _ => false,
2886            })
2887        });
2888        images_have_audio || messages_have_audio
2889    }
2890
2891    pub fn new(config: InferenceConfig) -> Self {
2892        let registry = models::ModelRegistry::new(config.models_dir.clone());
2893        let hw = HardwareInfo::detect();
2894        let policy_evidence =
2895            resource_policy::FileResourcePolicyRepository::new(config.state_root.clone())
2896                .load_with_evidence()
2897                .unwrap_or_else(|error| {
2898                    tracing::warn!(%error, "failed to read local model resource policy; using Everyday");
2899                    resource_policy::ResourcePolicyLoadEvidence {
2900                        policy: resource_policy::ResourcePolicy::everyday(),
2901                        source: resource_policy::ResourcePolicyLoadSource::CorruptDefault,
2902                        warning: Some(format!(
2903                            "The local-model resource policy could not be read ({error}); CAR used Everyday."
2904                        )),
2905                    }
2906                });
2907        let policy = policy_evidence.policy.clone();
2908        let effective_budget = policy.effective_budget(hw.total_ram_mb);
2909        let local_admission = resource_policy::scoped_local_admission_with_models_dir(
2910            &config.state_root,
2911            policy,
2912            hw.clone(),
2913            Some(config.models_dir.clone()),
2914        );
2915        let runtime_scope = scoped_inference_runtime(
2916            &config.state_root,
2917            effective_budget.configured_model_ceiling_mb,
2918            local_admission.clone(),
2919        );
2920        let router = ModelRouter::new(hw.clone());
2921        let unified_registry = UnifiedRegistry::new_with_state_root(
2922            config.state_root.clone(),
2923            config.models_dir.clone(),
2924        );
2925        let adaptive_router = AdaptiveRouter::with_default_config(hw);
2926        let mut tracker = OutcomeTracker::new();
2927        // Load persisted profiles from previous sessions (#13)
2928        let profiles_path = config.state_models_dir().join("outcome_profiles.json");
2929        if let Ok(n) = tracker.load_from_file(&profiles_path) {
2930            if n > 0 {
2931                tracing::info!(loaded = n, "loaded persisted model profiles");
2932            }
2933        }
2934        let mut benchmark_models_loaded = 0usize;
2935        for path in benchmark_priors_paths(&config.state_models_dir()) {
2936            match routing_ext::load_benchmark_priors(&path) {
2937                Ok(priors) if !priors.is_empty() => {
2938                    benchmark_models_loaded += priors.len();
2939                    routing_ext::apply_benchmark_priors(&mut tracker, &priors);
2940                    tracing::info!(
2941                        path = %path.display(),
2942                        loaded = priors.len(),
2943                        "loaded benchmark quality priors"
2944                    );
2945                }
2946                Ok(_) => {}
2947                Err(error) => {
2948                    tracing::warn!(path = %path.display(), %error, "failed to load benchmark priors");
2949                }
2950            }
2951        }
2952        if benchmark_models_loaded > 0 {
2953            tracing::info!(
2954                loaded = benchmark_models_loaded,
2955                "applied benchmark priors to cold-start routing"
2956            );
2957        }
2958        let outcome_tracker = Arc::new(RwLock::new(tracker));
2959
2960        let remote_backend = RemoteBackend::new();
2961        let model_management = model_management::ModelManagementStore::new(
2962            config.state_root.clone(),
2963            config.models_dir.clone(),
2964        );
2965
2966        Self {
2967            config,
2968            unified_registry,
2969            adaptive_router,
2970            outcome_tracker,
2971            last_outcome_flush: Arc::new(std::sync::Mutex::new(None)),
2972            ledger_io_lock: Arc::new(tokio::sync::Mutex::new(())),
2973            spend_limits: Arc::new(std::sync::RwLock::new(None)),
2974            lane_defaults_cache: Arc::new(std::sync::RwLock::new(crate::lane_defaults::load_from(
2975                &crate::lane_defaults::default_path(),
2976            ))),
2977            concierge_action_lock: Arc::new(tokio::sync::Mutex::new(())),
2978            concierge_action_seq: Arc::new(std::sync::atomic::AtomicU64::new(
2979                crate::action_ledger::read_actions(&crate::action_ledger::default_path(), 0)
2980                    .iter()
2981                    .map(|a| a.seq)
2982                    .max()
2983                    .map(|m| m + 1)
2984                    .unwrap_or(1),
2985            )),
2986            remote_backend,
2987            model_management,
2988            local_admission: local_admission.clone(),
2989            _runtime_scope: runtime_scope.clone(),
2990            resource_policy_generation: runtime_scope.resource_policy_generation.clone(),
2991            resource_policy_evidence: Arc::new(std::sync::RwLock::new(policy_evidence)),
2992            model_budget: runtime_scope.model_budget.clone(),
2993            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2994            mlx_backends: runtime_scope.mlx_backends.clone(),
2995            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2996            local_backends: runtime_scope.local_backends.clone(),
2997            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2998            flux_cache: runtime_scope.flux_cache.clone(),
2999            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3000            ltx_cache: runtime_scope.ltx_cache.clone(),
3001            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3002            kokoro_cache: runtime_scope.kokoro_cache.clone(),
3003            registry,
3004            router,
3005            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3006            backend: runtime_scope.backend.clone(),
3007            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3008            embedding_backend: runtime_scope.embedding_backend.clone(),
3009            speech_runtime: runtime_scope.speech_runtime.clone(),
3010            speech_policy: SpeechPolicy {
3011                prefer_local: cfg!(all(
3012                    target_os = "macos",
3013                    target_arch = "aarch64",
3014                    not(car_skip_mlx)
3015                )),
3016                allow_remote_fallback: true,
3017                preferred_local_stt: None,
3018                preferred_local_tts: None,
3019                preferred_remote_stt: None,
3020                preferred_remote_tts: None,
3021            },
3022            vllm_pool: runtime_scope.vllm_pool.clone(),
3023        }
3024    }
3025
3026    /// Initialize key pool: register keys from all remote models and load persisted stats.
3027    /// Call this after construction (requires async).
3028    pub async fn init_key_pool(&self) {
3029        // Register keys from all remote models in the catalog
3030        for schema in self.unified_registry.list() {
3031            if schema.is_remote() {
3032                self.remote_backend.register_model_keys(schema).await;
3033            }
3034        }
3035
3036        // Load persisted key stats
3037        let stats_path = self.config.state_models_dir().join("key_pool_stats.json");
3038        if let Ok(n) = self.remote_backend.key_pool.load_stats(&stats_path).await {
3039            if n > 0 {
3040                tracing::info!(loaded = n, "loaded persisted key pool stats");
3041            }
3042        }
3043
3044        let total = self.remote_backend.key_pool.total_keys().await;
3045        if total > 0 {
3046            tracing::info!(keys = total, "key pool initialized");
3047        }
3048    }
3049
3050    /// Get or initialize the generative Candle backend, loading the specified model.
3051    /// Not used on Apple Silicon where all local inference goes through MLX.
3052    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3053    async fn ensure_backend(
3054        &self,
3055        schema: &ModelSchema,
3056        reservation: &mut resource_policy::LocalLoadReservation,
3057    ) -> Result<(), InferenceError> {
3058        let read = self.backend.read().await;
3059        if read.contains_key(&schema.id) {
3060            return Ok(());
3061        }
3062        drop(read);
3063
3064        let mut write = self.backend.write().await;
3065        if write.contains_key(&schema.id) {
3066            return Ok(());
3067        }
3068
3069        let model_path = self.registry.ensure_model(&schema.name).await?;
3070        let mut measured = backend_cache::estimate_model_size(&model_path);
3071        reservation
3072            .reconcile_measured_weights(measured)
3073            .map_err(InferenceError::from)?;
3074        let device = self.config.device.unwrap_or_else(Device::auto);
3075        // LOCAL_ADMISSION_BOUNDARY:adaptive-local-dispatch
3076        let backend = match CandleBackend::load(&model_path, device) {
3077            Ok(b) => b,
3078            Err(load_err) => {
3079                // A load failure with a provably-corrupt cache (truncated/pruned
3080                // weights from the shared HF store) self-heals: purge the bad
3081                // files, re-pull, retry once. An intact cache surfaces the error.
3082                if crate::download::purge_corrupt_cache_files(&model_path) == 0 {
3083                    return Err(load_err);
3084                }
3085                tracing::warn!(
3086                    model = %schema.id,
3087                    error = %load_err,
3088                    "candle backend load failed; purged corrupt cache files and re-pulling once"
3089                );
3090                let model_path = self.registry.ensure_model(&schema.name).await?;
3091                measured = backend_cache::estimate_model_size(&model_path);
3092                reservation
3093                    .reconcile_measured_weights(measured)
3094                    .map_err(InferenceError::from)?;
3095                CandleBackend::load(&model_path, device)?
3096            }
3097        };
3098        write.insert(schema.id.clone(), backend);
3099        reservation.publish_resident_weights(measured);
3100        Ok(())
3101    }
3102
3103    /// Get or initialize the embedding backend.
3104    /// On Apple Silicon, uses the MLX backend instead of Candle.
3105    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3106    async fn ensure_embedding_backend(
3107        &self,
3108        reservation: &mut resource_policy::LocalLoadReservation,
3109    ) -> Result<(), InferenceError> {
3110        let read = self.embedding_backend.read().await;
3111        if read.is_some() {
3112            return Ok(());
3113        }
3114        drop(read);
3115
3116        let mut write = self.embedding_backend.write().await;
3117        if write.is_some() {
3118            return Ok(());
3119        }
3120
3121        let embedding_model = self
3122            .preferred_model_for_capability(ModelCapability::Embed)
3123            .unwrap_or(&self.config.embedding_model);
3124        let model_path = self.registry.ensure_model(embedding_model).await?;
3125        let mut measured = backend_cache::estimate_model_size(&model_path);
3126        reservation
3127            .reconcile_measured_weights(measured)
3128            .map_err(InferenceError::from)?;
3129        let device = self.config.device.unwrap_or_else(Device::auto);
3130        // LOCAL_ADMISSION_BOUNDARY:embedding-dispatch
3131        let backend = match EmbeddingBackend::load(&model_path, device) {
3132            Ok(b) => b,
3133            Err(load_err) => {
3134                if crate::download::purge_corrupt_cache_files(&model_path) == 0 {
3135                    return Err(load_err);
3136                }
3137                tracing::warn!(
3138                    model = embedding_model,
3139                    error = %load_err,
3140                    "embedding backend load failed; purged corrupt cache files and re-pulling once"
3141                );
3142                let model_path = self.registry.ensure_model(embedding_model).await?;
3143                measured = backend_cache::estimate_model_size(&model_path);
3144                reservation
3145                    .reconcile_measured_weights(measured)
3146                    .map_err(InferenceError::from)?;
3147                EmbeddingBackend::load(&model_path, device)?
3148            }
3149        };
3150        *write = Some(backend);
3151        reservation.publish_resident_weights(measured);
3152        Ok(())
3153    }
3154
3155    /// On Apple Silicon, ensure the MLX embedding model is loaded.
3156    /// Returns the schema ID of the embedding model for keying into mlx_backends.
3157    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3158    async fn ensure_mlx_embedding_backend(&self) -> Result<String, InferenceError> {
3159        let embedding_model_name = self
3160            .preferred_model_for_capability(ModelCapability::Embed)
3161            .unwrap_or(&self.config.embedding_model)
3162            .to_string();
3163        let schema = self
3164            .unified_registry
3165            .get(&embedding_model_name)
3166            .or_else(|| self.unified_registry.find_by_name(&embedding_model_name))
3167            .ok_or_else(|| InferenceError::ModelNotFound(embedding_model_name.clone()))?
3168            .clone();
3169        Ok(schema.id)
3170    }
3171
3172    /// Load a backend through `cache`, self-healing a corrupt model cache on
3173    /// failure.
3174    ///
3175    /// Loads via `loader`. If the load fails *and* a deep integrity check finds
3176    /// provably-corrupt files under `model_dir`, those files are purged, the
3177    /// model is re-pulled via `repull`, and the load is retried exactly once. A
3178    /// load failure with intact files — an unsupported model, a transient FFI
3179    /// panic, OOM — surfaces unchanged: we re-pull only when we can *prove* the
3180    /// on-disk cache is the problem (the shared HF cache is mutated by other
3181    /// tools, so a load failure is genuinely ambiguous between "bad weights" and
3182    /// "bad luck"). The deep sha256 pass runs only on this rare failure path and
3183    /// is bounded to weight blobs (`verify_cache_file` short-circuits configs).
3184    ///
3185    /// `get_or_load` does not cache a failed load, so the retry is a clean
3186    /// second attempt rather than a cached error.
3187    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3188    async fn load_backend_healing<T, F, RF, R>(
3189        schema_id: &str,
3190        model_dir: std::path::PathBuf,
3191        cache: &backend_cache::BackendCache<T>,
3192        size: u64,
3193        reservation: &mut resource_policy::LocalLoadReservation,
3194        loader: F,
3195        repull: RF,
3196    ) -> Result<
3197        (
3198            backend_cache::CachedBackend<T>,
3199            backend_cache::BackendRetention,
3200        ),
3201        InferenceError,
3202    >
3203    where
3204        T: Send + 'static,
3205        F: Fn(&Path) -> Result<T, InferenceError>,
3206        RF: FnOnce() -> R,
3207        R: std::future::Future<Output = Result<std::path::PathBuf, InferenceError>>,
3208    {
3209        match cache.get_or_load_admitted(schema_id, size, reservation, || loader(&model_dir)) {
3210            Ok(admitted) => Ok(admitted),
3211            Err(load_err) => {
3212                // The heal is deliberately lock-free: `repull` (redownload_local)
3213                // takes the per-model `acquire_model_lock` itself, so wrapping
3214                // this branch in the same lock would deadlock. The only cost is
3215                // that two callers racing the very first load of the same corrupt
3216                // model both fail — the first purges+heals, the second sees
3217                // `purged == 0` and surfaces the error. Rare and fail-safe: the
3218                // next call loads the now-healed model cleanly.
3219                let purged = crate::download::purge_corrupt_cache_files(&model_dir);
3220                if purged == 0 {
3221                    // Cache is intact — not a corruption we can heal by re-pulling.
3222                    return Err(load_err);
3223                }
3224                tracing::warn!(
3225                    model = schema_id,
3226                    purged,
3227                    error = %load_err,
3228                    "backend load failed; purged corrupt cache files and re-pulling once"
3229                );
3230                let fresh_dir = repull().await?;
3231                let fresh_size = backend_cache::estimate_model_size(&fresh_dir);
3232                cache
3233                    .get_or_load_admitted(schema_id, fresh_size, reservation, || loader(&fresh_dir))
3234            }
3235        }
3236    }
3237
3238    /// Get or initialize the native MLX backend for a specific model.
3239    /// Returns a shared mutex handle — the caller locks it for the
3240    /// duration of an inference call so concurrent requests serialize.
3241    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3242    async fn ensure_mlx_backend(
3243        &self,
3244        schema: &ModelSchema,
3245        reservation: &mut resource_policy::LocalLoadReservation,
3246    ) -> Result<
3247        (
3248            backend_cache::CachedBackend<backend::MlxBackend>,
3249            backend_cache::BackendRetention,
3250        ),
3251        InferenceError,
3252    > {
3253        if !Self::supports_native_mlx(schema) {
3254            return Err(InferenceError::InferenceFailed(format!(
3255                "native MLX backend does not support {} ({}) yet; use vLLM-MLX or add a family-specific MLX backend",
3256                schema.name, schema.family
3257            )));
3258        }
3259
3260        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3261        let size = backend_cache::estimate_model_size(&model_dir);
3262        if !reservation.authorizes_model(&schema.id) {
3263            return Err(InferenceError::InferenceFailed(format!(
3264                "local admission reservation does not authorize {}",
3265                schema.id
3266            )));
3267        }
3268        // Loader runs inside `get_or_load` only on a cache miss. Wrap it
3269        // in `catch_unwind` because MLX/accelerate occasionally panics
3270        // at the FFI boundary and we don't want the whole engine to die.
3271        let loader = |dir: &Path| {
3272            let dir = dir.to_path_buf();
3273            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3274                // LOCAL_ADMISSION_BOUNDARY:adaptive-local-dispatch
3275                backend::MlxBackend::load(&dir)
3276            }))
3277            .map_err(|e| {
3278                InferenceError::InferenceFailed(format!(
3279                    "MLX backend loading panicked (possible Metal/accelerate exception): {:?}",
3280                    e
3281                ))
3282            })?
3283        };
3284        Self::load_backend_healing(
3285            &schema.id,
3286            model_dir,
3287            &self.mlx_backends,
3288            size,
3289            reservation,
3290            loader,
3291            || self.unified_registry.redownload_local(&schema.id),
3292        )
3293        .await
3294    }
3295
3296    /// Load (and cache) a polymorphic in-process backend for a NEW-architecture
3297    /// MLX model — the trait-object analogue of `ensure_mlx_backend`, keyed
3298    /// into the separate `local_backends` cache. Dispatch on the model's
3299    /// `config.json` `model_type` lives in `backend::local::local_backend_for`.
3300    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3301    async fn ensure_local_backend(
3302        &self,
3303        schema: &ModelSchema,
3304        reservation: &mut resource_policy::LocalLoadReservation,
3305    ) -> Result<
3306        (
3307            backend_cache::CachedBackend<Box<dyn backend::local::LocalInferenceBackend>>,
3308            backend_cache::BackendRetention,
3309        ),
3310        InferenceError,
3311    > {
3312        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3313        let size = backend_cache::estimate_model_size(&model_dir);
3314        if !reservation.authorizes_model(&schema.id) {
3315            return Err(InferenceError::InferenceFailed(format!(
3316                "local admission reservation does not authorize {}",
3317                schema.id
3318            )));
3319        }
3320        let loader = |dir: &Path| {
3321            let dir = dir.to_path_buf();
3322            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3323                // LOCAL_ADMISSION_BOUNDARY:adaptive-local-dispatch
3324                backend::local::local_backend_for(&dir)
3325            }))
3326            .map_err(|e| {
3327                InferenceError::InferenceFailed(format!(
3328                    "local backend loading panicked (possible Metal/accelerate exception): {:?}",
3329                    e
3330                ))
3331            })?
3332        };
3333        Self::load_backend_healing(
3334            &schema.id,
3335            model_dir,
3336            &self.local_backends,
3337            size,
3338            reservation,
3339            loader,
3340            || self.unified_registry.redownload_local(&schema.id),
3341        )
3342        .await
3343    }
3344
3345    /// Clear the in-process KV / prefix cache of a loaded local model.
3346    ///
3347    /// Prefix reuse (`begin_prompt`) is a per-conversation optimization: it reuses
3348    /// the KV state of a shared token prefix across calls. When one engine is
3349    /// driven through a sequence of *independent* prompts (e.g. a benchmark's task
3350    /// suite), that reuse leaks decode state between unrelated conversations — and
3351    /// reusing cached KV instead of a fresh prefill introduces tiny numerical
3352    /// drift that can flip a greedy (temperature-0) token, making multi-step runs
3353    /// non-reproducible. Calling this between independent runs restores a clean
3354    /// slate. No-op for remote models or a backend that isn't currently loaded.
3355    pub async fn reset_local_kv_cache(&self, model_id: &str) {
3356        // The in-process `local_backends` cache (and its `ensure_local_backend`
3357        // loader) only exists on the native-MLX target; elsewhere there is no
3358        // such cache to clear, so this is a no-op.
3359        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3360        {
3361            let Some(schema) = self.unified_registry.get(model_id).cloned() else {
3362                return;
3363            };
3364            // Only the in-process backends (`Mlx`/`Local` GGUF) carry a KV cache;
3365            // remote sources have nothing to clear and must not be `ensure_local`-ed
3366            // (it would try to download weights).
3367            if !matches!(
3368                schema.source,
3369                ModelSource::Mlx { .. } | ModelSource::Local { .. }
3370            ) {
3371                return;
3372            }
3373            // Reset is observational maintenance, not a load boundary. Never
3374            // turn a cache miss into a cold model allocation.
3375            if !self.local_backends.contains(&schema.id) {
3376                return;
3377            }
3378            let Ok(mut reservation) = self.reserve_local_request(&schema, 0) else {
3379                return;
3380            };
3381            if let Ok((handle, _retention)) =
3382                self.ensure_local_backend(&schema, &mut reservation).await
3383            {
3384                if let Ok(mut guard) = handle.lock() {
3385                    guard.clear_kv_cache();
3386                }
3387            }
3388        }
3389        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3390        let _ = model_id;
3391    }
3392
3393    /// Pre-load a set of models into the MLX cache so the first real
3394    /// inference call doesn't pay the 1–14 s model-load latency. Safe
3395    /// to call multiple times; already-loaded models are no-ops.
3396    ///
3397    /// Handles both text-gen MLX backends (`mlx_backends`) and the
3398    /// image/video/tts caches. Pass the full `schema.id` values.
3399    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3400    pub async fn warm_up<S: AsRef<str>>(
3401        &self,
3402        schema_ids: &[S],
3403    ) -> Vec<Result<(), InferenceError>> {
3404        let mut results = Vec::with_capacity(schema_ids.len());
3405        for id in schema_ids {
3406            let id = id.as_ref();
3407            let outcome: Result<(), InferenceError> = async {
3408                let schema = self.unified_registry.get(id).cloned().ok_or_else(|| {
3409                    InferenceError::InferenceFailed(format!("warm_up: unknown schema id {id}"))
3410                })?;
3411                // LOCAL_ADMISSION_BOUNDARY:warm-up
3412                let mut reservation = self.reserve_local_request(&schema, 0)?;
3413                match schema.capabilities.first().copied() {
3414                    Some(ModelCapability::ImageGeneration) => {
3415                        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3416                        let size = backend_cache::estimate_model_size(&model_dir);
3417                        let _ = self.flux_cache.get_or_load_admitted(
3418                            &schema.id,
3419                            size,
3420                            &mut reservation,
3421                            || backend::mlx_flux::FluxBackend::load(&model_dir),
3422                        )?;
3423                    }
3424                    Some(ModelCapability::VideoGeneration) => {
3425                        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3426                        let size = backend_cache::estimate_model_size(&model_dir);
3427                        let _ = self.ltx_cache.get_or_load_admitted(
3428                            &schema.id,
3429                            size,
3430                            &mut reservation,
3431                            || {
3432                                // LOCAL_ADMISSION_BOUNDARY:video-dispatch
3433                                backend::mlx_ltx::LtxBackend::load(&model_dir)
3434                            },
3435                        )?;
3436                    }
3437                    Some(ModelCapability::TextToSpeech) => {
3438                        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3439                        let size = backend_cache::estimate_model_size(&model_dir);
3440                        let cache_key = reservation.model_id().to_string();
3441                        let _ = self.kokoro_cache.get_or_load_admitted(
3442                            &cache_key,
3443                            size,
3444                            &mut reservation,
3445                            || backend::mlx_kokoro::KokoroBackend::load(&model_dir),
3446                        )?;
3447                    }
3448                    _ => {
3449                        let _ = self.ensure_mlx_backend(&schema, &mut reservation).await?;
3450                    }
3451                }
3452                Ok(())
3453            }
3454            .await;
3455            results.push(outcome);
3456        }
3457        results
3458    }
3459
3460    /// No-op on non-macOS — MLX doesn't run here.
3461    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3462    pub async fn warm_up<S: AsRef<str>>(
3463        &self,
3464        _schema_ids: &[S],
3465    ) -> Vec<Result<(), InferenceError>> {
3466        Vec::new()
3467    }
3468
3469    /// Ensure the supervised `vllm-mlx` server for a `vllm-mlx/*` schema is
3470    /// running and return a copy whose endpoint points at the live loopback port.
3471    /// Non-vllm schemas pass through untouched. This is the seam that lets a
3472    /// server-backed (multimodal / unsupported-arch) model route exactly like an
3473    /// in-process one — the caller never starts a server or configures an endpoint.
3474    async fn vllm_live_schema(
3475        &self,
3476        schema: ModelSchema,
3477        reservation: Option<resource_policy::LocalLoadReservation>,
3478        context_tokens: usize,
3479    ) -> Result<(ModelSchema, Option<resource_policy::LocalLoadReservation>), InferenceError> {
3480        match &schema.source {
3481            ModelSource::ManagedVllmMlx { .. } => {}
3482            ModelSource::VllmMlx { .. } => return Ok((schema, None)),
3483            _ => return Ok((schema, None)),
3484        }
3485        // The gate begins before reaping/admission and ends only after the
3486        // ready process has been published resident. A concurrent request for
3487        // this model therefore waits, then reserves against the published
3488        // allocation instead of mistaking startup ownership for teardown.
3489        let _dispatch = self.vllm_pool.acquire_dispatch(&schema.id).await;
3490        self.vllm_pool
3491            .wait_for_teardown(&schema.id, std::time::Duration::from_secs(5))
3492            .await
3493            .map_err(InferenceError::InferenceFailed)?;
3494        // A dead leader may leave model-bearing descendants in its dedicated
3495        // process group. Reap/quarantine that group before taking a replacement
3496        // reservation; no new generation may peer-discount it while teardown
3497        // is pending.
3498        self.vllm_pool
3499            .reap_dead(&schema.id)
3500            .await
3501            .map_err(InferenceError::InferenceFailed)?;
3502        let mut reservation = match reservation {
3503            Some(reservation) if reservation.model_id() == schema.id => reservation,
3504            _ => self.reserve_local_request(&schema, context_tokens)?,
3505        };
3506        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3507        let model_name = model_dir.display().to_string();
3508        let measured = backend_cache::estimate_model_size(&model_dir)
3509            .max(reservation.reconciled_weights_bytes());
3510        reservation
3511            .reconcile_measured_weights(measured)
3512            .map_err(InferenceError::from)?;
3513        // LOCAL_ADMISSION_BOUNDARY:supervised-vllm-mlx
3514        let endpoint = self
3515            .vllm_pool
3516            // `family` selects the server-side reasoning parser; without it a
3517            // reasoning model's chain-of-thought comes back as the answer.
3518            .ensure(&schema.id, &model_name, &reservation, &schema.family)
3519            .await
3520            .map_err(InferenceError::InferenceFailed)?;
3521        let allocation_id = resource_policy::vllm_process_allocation_id(&schema.id);
3522        reservation.publish_resident_weights_as(&allocation_id, measured);
3523        let mut schema = schema;
3524        schema.source = ModelSource::VllmMlx {
3525            endpoint,
3526            model_name,
3527        };
3528        Ok((schema, Some(reservation)))
3529    }
3530
3531    /// Stop idle supervised `vllm-mlx` servers. Driven by the same idle loop that
3532    /// evicts in-process backends; returns the number stopped.
3533    pub async fn evict_idle_vllm_servers(&self) -> usize {
3534        self.vllm_pool.evict_idle().await
3535    }
3536
3537    /// Sweep idle model backends out of every LRU cache so a quiet daemon
3538    /// releases its resident model working set instead of pinning it under
3539    /// the (large) capacity budget — capacity eviction never fires below
3540    /// the cap, so without this a single loaded model stays resident
3541    /// forever. Returns `(entries_evicted, bytes_evicted)` summed across
3542    /// all backend caches. Idle window is `CAR_INFERENCE_MODEL_IDLE_SECS`
3543    /// (default 300; 0 disables). Drive it on a timer. (car-releases#67)
3544    ///
3545    /// No-op on platforms without the MLX caches (they hold a single
3546    /// replaceable backend rather than an accumulating cache).
3547    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3548    pub fn evict_idle_backends(&self) -> (usize, u64) {
3549        let mut entries = 0usize;
3550        let mut bytes = 0u64;
3551        for (n, b) in [
3552            self.mlx_backends.evict_idle(),
3553            self.local_backends.evict_idle(),
3554            self.flux_cache.evict_idle(),
3555            self.ltx_cache.evict_idle(),
3556            self.kokoro_cache.evict_idle(),
3557        ] {
3558            entries += n;
3559            bytes = bytes.saturating_add(b);
3560        }
3561        (entries, bytes)
3562    }
3563
3564    /// No-op on non-macOS — there are no accumulating backend caches.
3565    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3566    pub fn evict_idle_backends(&self) -> (usize, u64) {
3567        (0, 0)
3568    }
3569
3570    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3571    fn supports_native_mlx(schema: &ModelSchema) -> bool {
3572        matches!(schema.family.as_str(), "qwen3" | "qwen2.5-vl" | "qwen2-vl")
3573    }
3574
3575    fn catalog_registry_snapshot(&self) -> UnifiedRegistry {
3576        let mut registry = self.unified_registry.clone();
3577        registry.prune_missing_on_disk_models();
3578        // Startup/list/setup/health refreshes physical runtimes and local
3579        // weights, but credential-backed rows use environment presence or the
3580        // non-secret authority hint only.
3581        registry.refresh_availability();
3582        self.filter_disabled_local_models(&mut registry);
3583        registry
3584    }
3585
3586    fn filter_disabled_local_models(&self, registry: &mut UnifiedRegistry) {
3587        let disabled = registry
3588            .all()
3589            .filter(|schema| {
3590                schema.downloads_weights()
3591                    && !self
3592                        .model_management
3593                        .car_enabled(&schema.id)
3594                        .unwrap_or(false)
3595            })
3596            .map(|schema| schema.id.clone())
3597            .collect::<Vec<_>>();
3598        for model_id in disabled {
3599            registry.unregister(&model_id);
3600        }
3601    }
3602
3603    async fn routing_registry_snapshot_with_credential_failure(
3604        &self,
3605    ) -> (UnifiedRegistry, Option<RouteCredentialFailure>) {
3606        // The first explicit route/use is allowed to establish credential
3607        // truth. Parslee goes through Task 2's process-owned coordinator;
3608        // denial/cooldown remains distinct from an authoritative signed-out
3609        // result so passive observations are never cleared by an unreadable
3610        // Keychain.
3611        let parslee = car_auth::resolve_credential(car_auth::CredentialReadMode::Use).await;
3612        let (parslee_api_base, parslee_signed_out, credential_failure) = match &parslee {
3613            Ok(Some(credential)) => (Some(credential.api_base.as_str()), false, None),
3614            Ok(None) => (None, true, Some(parslee_signed_out_route_failure())),
3615            Err(error) => {
3616                let source_error = error.to_string();
3617                (
3618                    None,
3619                    false,
3620                    Some(RouteCredentialFailure {
3621                        summary: format!(
3622                            "Parslee {AUTH_STORE_UNREADABLE_MARKER} — unlock or grant access to the credential store, then retry"
3623                        ),
3624                        source_error,
3625                    }),
3626                )
3627            }
3628        };
3629        let mut registry = self.unified_registry.clone();
3630        registry.prune_missing_on_disk_models();
3631        registry.refresh_routing_availability(parslee_api_base, parslee_signed_out);
3632        self.filter_disabled_local_models(&mut registry);
3633        (registry, credential_failure)
3634    }
3635
3636    async fn routing_registry_snapshot(&self) -> UnifiedRegistry {
3637        self.routing_registry_snapshot_with_credential_failure()
3638            .await
3639            .0
3640    }
3641
3642    /// An explicit model does not need a global provider-credential sweep to
3643    /// be routed. Its backend resolves only its own credential at dispatch;
3644    /// local and external-runtime models resolve none. This keeps unrelated OS
3645    /// keychain latency off the managed-runtime admission and startup path.
3646    async fn request_routing_registry_snapshot(
3647        &self,
3648        requested_model: Option<&str>,
3649    ) -> UnifiedRegistry {
3650        if requested_model.is_some() {
3651            self.catalog_registry_snapshot()
3652        } else {
3653            self.routing_registry_snapshot().await
3654        }
3655    }
3656
3657    /// Route a prompt using the adaptive router (new). Returns full decision context.
3658    pub async fn route_adaptive(&self, prompt: &str) -> AdaptiveRoutingDecision {
3659        self.route_adaptive_with_intent(prompt, None).await
3660    }
3661
3662    /// Like [`route_adaptive`](Self::route_adaptive) but honors a caller
3663    /// [`IntentHint`] — notably `exclude_models`
3664    /// for adversarial-reviewer separation: "route me any capable model
3665    /// that is NOT the one that just did the work" (car#358). An excluded id
3666    /// is never chosen while any non-excluded capable model exists —
3667    /// including via the preferred-model override (skipped when it names an
3668    /// excluded model) and the cold-start fallbacks. The exclusion is soft:
3669    /// if excluding leaves nothing routable, an excluded model may still be
3670    /// returned as a last resort (a same-model review beats no review).
3671    pub async fn route_adaptive_with_intent(
3672        &self,
3673        prompt: &str,
3674        intent: Option<crate::intent::IntentHint>,
3675    ) -> AdaptiveRoutingDecision {
3676        if let Some(model) = self.preferred_model_for_capability(ModelCapability::Generate) {
3677            let exclude_set = self
3678                .adaptive_router
3679                .build_exclude_set(intent.as_ref(), &self.unified_registry);
3680            let overridden_is_excluded =
3681                Self::model_is_excluded(&exclude_set, &self.unified_registry, model);
3682            if !overridden_is_excluded {
3683                // Preferred-model routing never consulted refreshed
3684                // availability or credentials: it returned the configured
3685                // override either way. Read its context directly and avoid a
3686                // global credential/keychain sweep that cannot affect this
3687                // decision (and took ~182s in a scratch HOME).
3688                let ctx_len = self
3689                    .unified_registry
3690                    .get(model)
3691                    .or_else(|| self.unified_registry.find_by_name(model))
3692                    .map(|s| s.context_length)
3693                    .unwrap_or(0);
3694                return AdaptiveRoutingDecision {
3695                    model_id: model.to_string(),
3696                    model_name: model.to_string(),
3697                    task: InferenceTask::Generate,
3698                    complexity: TaskComplexity::assess(prompt),
3699                    reason: "preferred generation model override".into(),
3700                    strategy: RoutingStrategy::Explicit,
3701                    predicted_quality: 0.5,
3702                    fallbacks: vec![],
3703                    context_length: ctx_len,
3704                    needs_compaction: false,
3705                    candidates: vec![],
3706                };
3707            }
3708        }
3709        let routing_registry = self.routing_registry_snapshot().await;
3710        let tracker = self.outcome_tracker.read().await;
3711        match intent {
3712            Some(hint) => self
3713                .adaptive_router
3714                .route_with(crate::adaptive_router::RouteRequest {
3715                    intent: Some(&hint),
3716                    ..crate::adaptive_router::RouteRequest::new(prompt, &routing_registry, &tracker)
3717                }),
3718            None => self
3719                .adaptive_router
3720                .route(prompt, &routing_registry, &tracker),
3721        }
3722    }
3723
3724    /// Route a prompt to the best model without executing (legacy compat).
3725    pub fn route(&self, prompt: &str) -> RoutingDecision {
3726        self.router.route_generate(prompt, &self.registry)
3727    }
3728
3729    /// Estimate token count for a request against a specific model's context window.
3730    /// Returns (estimated_input_tokens, context_window_tokens, fits).
3731    ///
3732    /// Multimodal content blocks (image/video/audio, in `images` or in
3733    /// `messages` history) contribute provider-calibrated estimates via
3734    /// [`media_tokens`] — a minute of video is ~15.8K input tokens at
3735    /// Gemini's documented rate, not zero — and the multi-turn
3736    /// `messages` history's *text* is counted too (chars/4), not just
3737    /// its media. This feeds the adaptive router's window-fit /
3738    /// `needs_compaction` signal.
3739    pub fn estimated_tokens(
3740        &self,
3741        req: &GenerateRequest,
3742        model_id: Option<&str>,
3743    ) -> (usize, usize, bool) {
3744        let prompt_tokens = remote::estimate_tokens(&req.prompt);
3745        let context_tokens = req
3746            .context
3747            .as_ref()
3748            .map(|c| remote::estimate_tokens(c))
3749            .unwrap_or(0);
3750        let tools_tokens = req
3751            .tools
3752            .as_ref()
3753            .map(|t| remote::estimate_tokens(&serde_json::to_string(t).unwrap_or_default()))
3754            .unwrap_or(0);
3755        let media_tokens = media_tokens::request_media_and_history_tokens(
3756            req.images.as_deref(),
3757            req.messages.as_deref(),
3758        );
3759        let total_input = prompt_tokens + context_tokens + tools_tokens + media_tokens;
3760
3761        // Build the passive catalog snapshot ONLY when there is an id to look
3762        // up (car-releases#75). It refreshes local weights/runtime readiness
3763        // plus non-secret environment/authority hints; it performs no secret
3764        // store reads. All three in-crate callers pass `model_id: None`, so
3765        // constructing and discarding even that snapshot would be unnecessary;
3766        // the `and_then` short-circuits on None and `context_window` is 0 either
3767        // way. Request-time routing establishes authoritative credential truth
3768        // separately through `routing_registry_snapshot`.
3769        let context_window = match model_id {
3770            Some(id) => {
3771                let routing_registry = self.catalog_registry_snapshot();
3772                routing_registry
3773                    .get(id)
3774                    .or_else(|| routing_registry.find_by_name(id))
3775                    .map(|s| s.context_length)
3776                    .unwrap_or(0)
3777            }
3778            None => 0,
3779        };
3780
3781        let fits = context_window == 0 || (total_input + req.params.max_tokens) <= context_window;
3782        (total_input, context_window, fits)
3783    }
3784
3785    /// Normalize caller-supplied cache estimates to the prompt footprint the
3786    /// router is pricing. Cache reads and writes are mutually exclusive token
3787    /// buckets in [`CostModel::estimated_usd`], so their sum must never exceed
3788    /// the total estimated input. Zero stays zero: CAR does not infer a cache
3789    /// hit/write merely because protocol-level cache controls are enabled.
3790    fn routing_cache_estimates(req: &GenerateRequest, estimated_input: usize) -> (usize, usize) {
3791        let read = req
3792            .params
3793            .estimated_cache_read_input_tokens
3794            .min(estimated_input);
3795        let write = req
3796            .params
3797            .estimated_cache_write_input_tokens
3798            .min(estimated_input.saturating_sub(read));
3799        (read, write)
3800    }
3801
3802    /// The model's context window in tokens, or 0 if the id is unknown
3803    /// (unregistered). Public so a multi-turn driver (e.g. the assistant
3804    /// loop) can bound its running message history to the window *before*
3805    /// it overflows — an overflowed history pushes the model to its context
3806    /// limit and can truncate the original task provider-side.
3807    pub fn model_context_window(&self, model_id: &str) -> usize {
3808        let routing_registry = self.catalog_registry_snapshot();
3809        routing_registry
3810            .get(model_id)
3811            .or_else(|| routing_registry.find_by_name(model_id))
3812            .map(|s| s.context_length)
3813            .unwrap_or(0)
3814    }
3815
3816    /// Generate text with full tracking (tool_calls, usage, trace_id,
3817    /// latency, TTFT), plus Qwen3 hybrid-thinking recovery.
3818    ///
3819    /// Qwen3 (and other hybrid-thinking models) default to reasoning ON.
3820    /// With a small `max_tokens` budget the model can spend the entire
3821    /// budget inside an unclosed `<think>` block, so the strip pass returns
3822    /// empty text — `infer(prompt, model, 16)` then silently yields "" while
3823    /// a non-thinking model answers fine (car-releases#60, #62).
3824    ///
3825    /// When the caller left `thinking` on `Auto` (didn't explicitly opt into
3826    /// reasoning) and nothing usable came back, retry once with reasoning
3827    /// suppressed so the caller gets a direct answer — matching the CLI's
3828    /// `--thinking off` default, but for every FFI/daemon path. Either way,
3829    /// record *why* via `stop_reason` so an empty result is never silent.
3830    pub async fn generate_tracked(
3831        &self,
3832        req: GenerateRequest,
3833    ) -> Result<InferenceResult, InferenceError> {
3834        let mut ignore_retry = |_: InferenceRetryProgress| {};
3835        self.generate_tracked_with_retry_observer(req, &mut ignore_retry)
3836            .await
3837    }
3838
3839    /// Generate text while reporting retries that happen inside a provider
3840    /// request. The ordinary [`Self::generate_tracked`] path is identical but
3841    /// discards these progress notifications.
3842    pub async fn generate_tracked_with_retry_observer(
3843        &self,
3844        req: GenerateRequest,
3845        retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
3846    ) -> Result<InferenceResult, InferenceError> {
3847        crate::offload::ensure_not_controlled_terminated()?;
3848        let catalog_snapshot = self
3849            .catalog_snapshot()
3850            .map_err(InferenceError::InferenceFailed)?;
3851        let recover = matches!(req.params.thinking, ThinkingMode::Auto);
3852        let mut result = self
3853            .generate_tracked_inner(req.clone(), &catalog_snapshot, retry_observer)
3854            .await?;
3855
3856        let action = classify_empty_pass(
3857            recover,
3858            result.stop_reason.as_deref(),
3859            &result.text,
3860            result.tool_calls.is_empty(),
3861        );
3862        let hit_decode_ceiling = action == EmptyPassAction::FailDecodeCeiling;
3863
3864        if action == EmptyPassAction::RetryWithoutThinking {
3865            result.stop_reason = Some("thinking_truncated".to_string());
3866            let mut retry = req;
3867            retry.params.thinking = ThinkingMode::Off;
3868            crate::offload::ensure_not_controlled_terminated()?;
3869            match self
3870                .generate_tracked_inner(retry, &catalog_snapshot, retry_observer)
3871                .await
3872            {
3873                Ok(mut recovered) => {
3874                    if !recovered.text.trim().is_empty() || !recovered.tool_calls.is_empty() {
3875                        recovered.stop_reason = Some("thinking_recovered".to_string());
3876                        return Ok(recovered);
3877                    }
3878                }
3879                Err(error @ InferenceError::ControlledTermination) => return Err(error),
3880                Err(_) => {}
3881            }
3882        }
3883
3884        // A ceiling stop that produced nothing usable is a FAILED turn, and it
3885        // has to read like one. Returning `Ok` with empty text hands the caller
3886        // a turn it cannot act on: `car do` printed nothing and exited 0, which
3887        // is the same silence car#851 was reported for, just bounded. Partial
3888        // text still comes back as `Ok` — it is worth something to the caller.
3889        if hit_decode_ceiling && result.text.trim().is_empty() && result.tool_calls.is_empty() {
3890            return Err(InferenceError::InferenceFailed(format!(
3891                "local generation hit its {}s wall-clock ceiling before producing any output. \
3892                 The `local prefill starting` / `local decode in progress` log lines show where \
3893                 the time went — a large prompt can spend most of it on prefill. Try a smaller \
3894                 model, shorten the prompt, check for another process contending for the GPU, \
3895                 or raise the ceiling with CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables it).",
3896                local_decode_timeout().map_or(0, |t| t.as_secs())
3897            )));
3898        }
3899
3900        Ok(result)
3901    }
3902
3903    #[instrument(
3904        name = "inference.generate",
3905        skip_all,
3906        fields(
3907            model = tracing::field::Empty,
3908            max_tokens = req.params.max_tokens,
3909            prompt_tokens = tracing::field::Empty,
3910            completion_tokens = tracing::field::Empty,
3911            latency_ms = tracing::field::Empty,
3912        )
3913    )]
3914    async fn generate_tracked_inner(
3915        &self,
3916        mut req: GenerateRequest,
3917        catalog_snapshot: &CatalogSnapshot,
3918        retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
3919    ) -> Result<InferenceResult, InferenceError> {
3920        crate::offload::ensure_not_controlled_terminated()?;
3921        validate_expected_catalog_revision(&req, catalog_snapshot)?;
3922        let requested_model_id = exact_pinned_model_id(&req).map(str::to_string);
3923        if let Some(model_id) = requested_model_id.as_ref() {
3924            req.model = Some(model_id.clone());
3925            req.params.strict_model = true;
3926        }
3927        let start = Instant::now();
3928        let has_requested_route = req.model.is_some();
3929        let (routing_registry, initial_route_credential_failure) = if has_requested_route {
3930            (self.catalog_registry_snapshot(), None)
3931        } else {
3932            self.routing_registry_snapshot_with_credential_failure()
3933                .await
3934        };
3935        if let Some(requested) = requested_model_id.as_deref() {
3936            if routing_registry.get(requested).is_none() {
3937                return Err(InferenceError::ModelNotFound(requested.to_string()));
3938            }
3939        } else if let Some(requested) = req.model.as_deref() {
3940            if routing_registry
3941                .get(requested)
3942                .or_else(|| routing_registry.find_by_name(requested))
3943                .is_none()
3944            {
3945                return Err(InferenceError::ModelNotFound(requested.to_string()));
3946            }
3947        }
3948
3949        // Route using adaptive router (context-aware)
3950        let (estimated_input, _, _) = self.estimated_tokens(&req, None);
3951        // Full context footprint = input + the reserved output budget. The
3952        // router's fit / needs_compaction check compares this against each
3953        // model's context_length. Passing input ALONE (as it used to) let a
3954        // prompt that fits but leaves no room for `max_tokens` of output route
3955        // without a compaction signal, then overflow mid-generation. Matches
3956        // the engine's own `estimated_tokens` fit formula (input + max_tokens).
3957        // `estimated_input` is kept separately for token accounting below.
3958        let estimated_footprint = estimated_input.saturating_add(req.params.max_tokens);
3959        let (estimated_cache_read, estimated_cache_write) =
3960            Self::routing_cache_estimates(&req, estimated_input);
3961        let tracker_read = self.outcome_tracker.read().await;
3962        let has_tools = Self::request_has_tools(&req);
3963        let has_vision = Self::request_needs_vision(&req);
3964        let preferred_model = self
3965            .preferred_model_for_capability(ModelCapability::Generate)
3966            .map(str::to_string);
3967        let exclude_set = self
3968            .adaptive_router
3969            .build_exclude_set(req.intent.as_ref(), &routing_registry);
3970        let unpinned_override = self
3971            .lane_pin_for(&req, &routing_registry)
3972            .or(preferred_model)
3973            .filter(|model| !Self::model_is_excluded(&exclude_set, &routing_registry, model));
3974        let decision = match req.model.clone().or(unpinned_override) {
3975            Some(m) => {
3976                let ctx_len = routing_registry
3977                    .get(&m)
3978                    .or_else(|| routing_registry.find_by_name(&m))
3979                    .map(|s| s.context_length)
3980                    .unwrap_or(0);
3981                AdaptiveRoutingDecision {
3982                    model_id: m.clone(),
3983                    model_name: m.clone(),
3984                    task: InferenceTask::Generate,
3985                    complexity: TaskComplexity::assess(&req.prompt),
3986                    reason: "explicit model".into(),
3987                    strategy: RoutingStrategy::Explicit,
3988                    predicted_quality: 0.5,
3989                    fallbacks: vec![],
3990                    context_length: ctx_len,
3991                    needs_compaction: ctx_len > 0 && estimated_footprint > ctx_len,
3992                    candidates: vec![],
3993                }
3994            }
3995            None => self
3996                .adaptive_router
3997                .route_with(crate::adaptive_router::RouteRequest {
3998                    estimated_total_tokens: estimated_footprint,
3999                    estimated_input_tokens: estimated_input,
4000                    estimated_output_tokens: req.params.max_tokens,
4001                    estimated_cache_read_tokens: estimated_cache_read,
4002                    estimated_cache_write_tokens: estimated_cache_write,
4003                    has_tools,
4004                    has_vision,
4005                    workload: req.params.workload,
4006                    intent: req.intent.as_ref(),
4007                    ..crate::adaptive_router::RouteRequest::new(
4008                        &req.prompt,
4009                        &routing_registry,
4010                        &tracker_read,
4011                    )
4012                }),
4013        };
4014        drop(tracker_read);
4015
4016        if decision.model_id.is_empty() {
4017            let excluded_models = req
4018                .intent
4019                .as_ref()
4020                .map(|hint| hint.exclude_models.join(", "))
4021                .unwrap_or_default();
4022            return Err(InferenceError::NoEligibleModel { excluded_models });
4023        }
4024
4025        if decision.needs_compaction {
4026            tracing::info!(
4027                model = %decision.model_name,
4028                prompt_tokens = estimated_input,
4029                context_window = decision.context_length,
4030                "prompt exceeds model context window — compaction or truncation needed"
4031            );
4032        }
4033
4034        // NOTE: the outcome trace is opened per-candidate inside the fallback
4035        // loop below (`attempt_trace`), not once here. A single shared trace
4036        // mis-attributed a fallback success to the first model and let the
4037        // post-loop failure double-book the first candidate.
4038        debug!(
4039            model = %decision.model_name,
4040            strategy = ?decision.strategy,
4041            reason = %decision.reason,
4042            "adaptive-routed generate request"
4043        );
4044
4045        // Auto-enable extended thinking for complex tasks when the model supports it
4046        // and the caller hasn't explicitly set budget_tokens.
4047        let mut req = req;
4048
4049        // Default per-turn output budget from the resolved model when the
4050        // caller left it at the library default (4096). Prevents tool_use JSON
4051        // truncation runaways on long-horizon tasks (car-cli run_task) —
4052        // except for models decoded in-process, where the budget is wall clock.
4053        // See `resolved_max_tokens` (car#851).
4054        if let Some(schema) = routing_registry
4055            .get(&decision.model_id)
4056            .or_else(|| routing_registry.find_by_name(&decision.model_id))
4057        {
4058            req.params.max_tokens = resolved_max_tokens(req.params.max_tokens, schema);
4059        }
4060
4061        // Auto-enable extended/interleaved thinking for reasoning-heavy AND
4062        // CODING turns on models that support it. Coding turns arrive as
4063        // InferenceTask::Code (the coder/bench send IntentHint{task:Code}); they
4064        // never classify as TaskComplexity::Complex, which is exactly why coding
4065        // had 0 thinking budget on every turn. Code gets a higher budget ("high"
4066        // effort) than a general Complex task ("medium"). (F1, audit 2026-07-06.)
4067        // Key the coding budget on the caller's EXPLICIT intent, NOT the keyword
4068        // classifier's decision.task (see `is_explicit_code_intent`).
4069        let is_code_intent = is_explicit_code_intent(req.intent.as_ref());
4070        let is_complex = matches!(decision.complexity, TaskComplexity::Complex);
4071        if req.params.budget_tokens == 0 && (is_code_intent || is_complex) {
4072            // Same id-then-name resolution as the max-tokens defaulting
4073            // above — a name-only route must not silently skip the
4074            // auto-budget.
4075            let supports_thinking = routing_registry
4076                .get(&decision.model_id)
4077                .or_else(|| routing_registry.find_by_name(&decision.model_id))
4078                .map(|s| {
4079                    s.supported_params
4080                        .contains(&schema::GenerateParam::ExtendedThinking)
4081                })
4082                .unwrap_or(false);
4083            if let Some(budget) =
4084                auto_thinking_budget(is_code_intent, is_complex, supports_thinking)
4085            {
4086                req.params.budget_tokens = budget;
4087                tracing::info!(
4088                    model = %decision.model_name,
4089                    budget,
4090                    code_intent = is_code_intent,
4091                    "auto-enabled extended thinking"
4092                );
4093            }
4094        }
4095
4096        // Execute — dispatch to local or remote backend, with fallback on failure
4097        let mut models_to_try = vec![decision.model_id.clone()];
4098        models_to_try.extend(decision.fallbacks.iter().cloned());
4099
4100        // Resilience last resort: append an installed on-device model to the
4101        // tail of the chain when nothing already in it is local. An explicitly
4102        // requested / substituted model (e.g. the assistant's `parslee/advisor`)
4103        // ships with an EMPTY fallback list, so a single cloud failure — an
4104        // expired Parslee credential, a 401, an offline network — otherwise
4105        // errors out with "remaining=0" even on a machine with a working local
4106        // GPU model. Degrading to on-device beats failing. Only added when the
4107        // chain is entirely remote; a local primary/fallback already covers it.
4108        //
4109        // EXCEPT under a hard pin (`strict_model`): a caller that pinned a
4110        // specific backbone (the coder's `--model`, an A/B arm) needs the pinned
4111        // model or a loud error — NOT a silent swap to a weaker local model,
4112        // which manufactures fake results (a mid-run Parslee outage once
4113        // degraded a gpt-5.5 coder A/B to local Qwen and fabricated losses).
4114        let chain_has_local = models_to_try.iter().any(|m| {
4115            routing_registry
4116                .get(m)
4117                .or_else(|| routing_registry.find_by_name(m))
4118                .map(|s| s.is_local())
4119                .unwrap_or(false)
4120        });
4121        let mut local_last_resort_id = None;
4122        if should_append_local_last_resort(chain_has_local, req.params.strict_model) {
4123            // Tool-aware: for a tools-bearing turn, only a tool-capable local
4124            // model can serve it (a text-only one is dropped by the ToolUse
4125            // guard below), so require that capability before appending.
4126            if let Some(local) = self.first_installed_local_model(has_tools) {
4127                tracing::info!(
4128                    local_model = %local,
4129                    needs_tools = has_tools,
4130                    "appended on-device model as last-resort fallback (chain was remote-only)"
4131                );
4132                models_to_try.push(local.clone());
4133                local_last_resort_id = Some(local);
4134            }
4135        } else if !chain_has_local && req.params.strict_model {
4136            tracing::info!(
4137                model = %decision.model_id,
4138                "strict_model set — not degrading to on-device; a remote failure will surface as an error"
4139            );
4140        }
4141
4142        let mut last_error = None;
4143        // The FIRST candidate whose credential was rejected (401/403/expired),
4144        // remembered so a later candidate's success can announce the degrade
4145        // rather than quietly serving a different model (Parslee-ai/car#888).
4146        // Only meaningful when the chain goes on to succeed — an exhausted
4147        // chain already surfaces `auth_expired_recovery_hint`.
4148        let mut auth_dead_lane: Option<String> = None;
4149        // Unlike `auth_dead_lane` (success-path degrade metadata), this holds
4150        // the human-actionable cause for an exhausted chain. Each later
4151        // configured/attempted credential failure replaces an earlier one;
4152        // ambient missing-variable noise from unconfigured fallback providers
4153        // is ignored. The routing snapshot's signed-out / store-unreadable
4154        // pre-seed only survives when a Parslee route is actually part of this
4155        // chain. Another provider's outage or a local OOM cannot be repaired
4156        // with the Parslee login; actual provider auth failures below still
4157        // replace this slot regardless of which providers were selected.
4158        let mut route_credential_failure = if chain_includes_parslee_route(
4159            |m| {
4160                routing_registry
4161                    .get(m)
4162                    .or_else(|| routing_registry.find_by_name(m))
4163            },
4164            &models_to_try,
4165        ) {
4166            initial_route_credential_failure
4167        } else {
4168            None
4169        };
4170        // Every skipped lane, not only the auth-rejected one. This ordered
4171        // history stays separate from both credential slots — see
4172        // `InferenceResult::fallback_from`.
4173        let mut fallback_hops: Vec<FallbackFrom> = Vec::new();
4174
4175        // Pop-front queue (not `for .. in &models_to_try`) so the I4
4176        // failover below can promote a cross-provider fallback to the
4177        // front when the primary fails with a transient provider error.
4178        // A queue keeps the body's pre-existing `continue`s safe — the
4179        // candidate is already popped, so `continue` moves on instead of
4180        // retrying the same candidate forever (linus review, critical 1).
4181        let mut candidate_queue: std::collections::VecDeque<String> =
4182            models_to_try.iter().cloned().collect();
4183        let mut is_primary_attempt = true;
4184        while let Some(candidate_owned) = candidate_queue.pop_front() {
4185            crate::offload::ensure_not_controlled_terminated()?;
4186            let was_primary = is_primary_attempt;
4187            is_primary_attempt = false;
4188            let candidate_id = &candidate_owned;
4189            // `mut` is needed on the aarch64-macos cfg branch below;
4190            // other targets don't rebind.
4191            #[allow(unused_mut)]
4192            let mut schema = routing_registry
4193                .get(candidate_id)
4194                .or_else(|| routing_registry.find_by_name(candidate_id))
4195                .cloned();
4196
4197            // On Apple Silicon, redirect GGUF/Candle models to their MLX
4198            // equivalents. The adaptive router now pre-resolves this before
4199            // scoring (#333), so for router-proposed candidates this is a
4200            // no-op; it remains load-bearing for the legacy explicit-model
4201            // path (`req.model` set), which bypasses the router entirely. An
4202            // exact immutable `model_id` pin must dispatch the named row and
4203            // therefore deliberately bypasses this compatibility substitution.
4204            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4205            if requested_model_id.is_none() {
4206                if let Some(ref s) = schema {
4207                    if let Some(mlx_equiv) = routing_registry.resolve_mlx_equivalent(s) {
4208                        tracing::info!(
4209                            from = %s.id, to = %mlx_equiv.id,
4210                            "redirecting GGUF model to MLX equivalent on Apple Silicon"
4211                        );
4212                        schema = Some(mlx_equiv.clone());
4213                    }
4214                }
4215            }
4216
4217            // Bound before the capability guard below so a candidate skipped
4218            // there can be named in the fallback hop list — that guard is the
4219            // one capability mismatch car#1351 calls out, and it is reachable
4220            // by the two paths its own comment names.
4221            let candidate_name = schema
4222                .as_ref()
4223                .map(|s| s.name.clone())
4224                .unwrap_or_else(|| candidate_id.clone());
4225
4226            // Tool-capability guard (honest routing): a tools-bearing request
4227            // must land on a backend that actually parses tool calls. The
4228            // adaptive router filters on the ToolUse capability, but the
4229            // explicit-model path bypasses it and the cold-start "last resort"
4230            // can hand back a capability-lacking default. The in-process
4231            // mlx/candle generate path ignores `tools` entirely, so without
4232            // this guard the model would silently return prose for a tool
4233            // request. Skip this candidate (let fallback try a capable one); if
4234            // none qualifies, the loop surfaces UnsupportedMode below instead of
4235            // a misleading text answer.
4236            if has_tools
4237                && schema
4238                    .as_ref()
4239                    .map(|s| !s.has_capability(ModelCapability::ToolUse))
4240                    .unwrap_or(false)
4241            {
4242                let backend = schema
4243                    .as_ref()
4244                    .map(|s| if s.is_local() { "local" } else { "remote" })
4245                    .unwrap_or("unknown");
4246                tracing::warn!(
4247                    model = %candidate_id,
4248                    backend,
4249                    "tools requested but resolved model lacks ToolUse capability — skipping candidate"
4250                );
4251                let unsupported = InferenceError::UnsupportedMode {
4252                    mode: "tool_use",
4253                    backend,
4254                    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)",
4255                };
4256                // Recorded: this is the capability mismatch car#1351 names by
4257                // hand, and the guard's own comment above says it is reachable
4258                // — the explicit-model path bypasses the router's filter and
4259                // the cold-start last resort can hand back a default without
4260                // ToolUse.
4261                record_fallback_from(&mut fallback_hops, &candidate_name, &unsupported);
4262                last_error = Some(unsupported);
4263                continue;
4264            }
4265
4266            // Book outcomes against the *resolved canonical id* (`schema.id`,
4267            // post-MLX-redirect) — not the raw `candidate_id` the caller
4268            // passed. An explicit alias like `claude-sonnet-4-6` and the
4269            // catalog id `anthropic/claude-sonnet-4-6:latest` resolve to the
4270            // same schema, so recording the raw alias fragmented the health
4271            // surface into two "models" for one physical model (the
4272            // high-volume non-streaming path's half of the split). Mirrors
4273            // `generate_stream_raw`'s `resolved_model_id`, which already does
4274            // this for the streaming path. Falls back to the raw id only when
4275            // the model is unknown to the registry.
4276            let resolved_id = schema
4277                .as_ref()
4278                .map(|s| s.id.clone())
4279                .unwrap_or_else(|| candidate_id.clone());
4280            let reported_model_used = if requested_model_id.is_some() {
4281                resolved_id.clone()
4282            } else {
4283                candidate_name.clone()
4284            };
4285            validate_expected_catalog_row(&req, catalog_snapshot, &resolved_id)?;
4286
4287            // An exact immutable id is an identity contract, not a request for
4288            // the nearest runnable implementation. Candle/GGUF execution is
4289            // disabled on Apple Silicon; fail before the worker boundary
4290            // instead of sending a legacy plain-model request that the worker
4291            // could silently redirect to the MLX twin.
4292            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4293            if requested_model_id.is_some()
4294                && schema
4295                    .as_ref()
4296                    .is_some_and(|schema| matches!(schema.source, ModelSource::Local { .. }))
4297            {
4298                let pinned = InferenceError::InferenceFailed(format!(
4299                    "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"
4300                ));
4301                record_fallback_from(&mut fallback_hops, &candidate_name, &pinned);
4302                last_error = Some(pinned);
4303                continue;
4304            }
4305
4306            let is_remote = schema
4307                .as_ref()
4308                .map(|s| s.is_remote() || s.is_vllm_mlx())
4309                .unwrap_or(false);
4310            let is_codex_cli = schema.as_ref().map(|s| s.is_codex_cli()).unwrap_or(false);
4311            let is_delegated = schema.as_ref().map(|s| s.is_delegated()).unwrap_or(false);
4312
4313            // Open one outcome trace per candidate attempt, attributed to THIS
4314            // model id. Success (record_complete) and failure (record_failure)
4315            // both resolve this same trace, so each attempt books exactly one
4316            // outcome against the right model.
4317            let attempt_trace = {
4318                let mut tracker = self.outcome_tracker.write().await;
4319                tracker.record_start(&resolved_id, decision.task, &decision.reason)
4320            };
4321
4322            // Delegated dispatch (Parslee-ai/car-releases#24) — route
4323            // the synchronous path through the runner the same way
4324            // the streaming path does, then accumulate. Done before
4325            // the tools-context massaging because delegated models
4326            // own their own prompt construction.
4327            if is_delegated {
4328                let runner = match runner::current_inference_runner() {
4329                    Some(r) => r,
4330                    None => {
4331                        let msg = "model declares ModelSource::Delegated but no inference runner is registered";
4332                        self.outcome_tracker
4333                            .write()
4334                            .await
4335                            .record_failure(&attempt_trace, msg);
4336                        let unregistered = InferenceError::InferenceFailed(msg.into());
4337                        record_fallback_from(&mut fallback_hops, &candidate_name, &unregistered);
4338                        last_error = Some(unregistered);
4339                        continue;
4340                    }
4341                };
4342                let (tx, mut rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
4343                let emitter = runner::EventEmitter::new(tx);
4344                let runner_req = req.clone();
4345                let runner_handle = AbortOnDropTask(Some(tokio::spawn(async move {
4346                    runner.run(runner_req, emitter).await
4347                })));
4348                let mut accumulator = stream::StreamAccumulator::default();
4349                while let Some(evt) = rx.recv().await {
4350                    accumulator.push(&evt);
4351                }
4352                // Wait for the runner future so its return value is
4353                // observed. The accumulator is preferred when it has anything,
4354                // because a streaming runner's deltas are the authoritative
4355                // text; but a runner that emits NO events and answers with
4356                // `inference.runner.complete` alone is legitimate — a delegated
4357                // model returning a short non-streaming answer has nothing to
4358                // stream. Falling back to `RunnerResult` in that case is what
4359                // makes complete-alone terminal (Parslee-ai/car-releases#76).
4360                //
4361                // Discarding it was worse than losing the text. An empty result
4362                // trips the ThinkingMode::Auto truncation-recovery retry in
4363                // `generate_tracked`, which re-runs the WHOLE call — so the
4364                // runner is invoked a second time, wall time doubles, and
4365                // `latency_ms` (stamped in here, per leg) reports half of it.
4366                // `finish_with_usage`, not `finish` (#795). The accumulator
4367                // already captures `StreamEvent::Usage` — a runner that reports
4368                // counts had them collected and then thrown away one line before
4369                // they were needed, so every delegated call reported
4370                // `usage: null`. A consumer summing `total_tokens` read a silent
4371                // zero, which is worse than an error because it looks valid.
4372                //
4373                // Still `None` when the runner emits no usage event; that is
4374                // honest — CAR cannot know a foreign runner's tokenization — and
4375                // callers can fall back to their own estimator, which is what
4376                // `finish_with_usage` documents. The provider stop_reason comes
4377                // back on the same tuple and was being dropped too; it feeds
4378                // `InferenceResult::was_truncated`, which read as "not truncated"
4379                // for every delegated call.
4380                let (acc_text, acc_tool_calls, acc_usage, acc_stop_reason) =
4381                    accumulator.finish_with_usage();
4382                match runner_handle.join().await {
4383                    Ok(Ok(runner_result)) => {
4384                        let elapsed = start.elapsed().as_millis() as u64;
4385                        let acc_text = if acc_text.trim().is_empty() {
4386                            runner_result.text
4387                        } else {
4388                            acc_text
4389                        };
4390                        let acc_tool_calls = if acc_tool_calls.is_empty() {
4391                            runner_result.tool_calls
4392                        } else {
4393                            acc_tool_calls
4394                        };
4395                        // Estimate output tokens from the accumulated text so
4396                        // this delegated-runner path (NAPI/registered runners)
4397                        // records real token stats AND qualifies for the #312
4398                        // mechanical-success credit — a hardcoded 0 here failed
4399                        // the `output_tokens > 0` gate in outcome::sweep_pending
4400                        // and left these models stuck at the 0.5 EMA prior.
4401                        let est_out = acc_text.split_whitespace().count();
4402                        {
4403                            let mut tracker = self.outcome_tracker.write().await;
4404                            tracker.record_complete(
4405                                &attempt_trace,
4406                                elapsed,
4407                                estimated_input,
4408                                est_out,
4409                            );
4410                        }
4411                        let local_last_resort = report_local_last_resort_served(
4412                            local_last_resort_id.as_deref(),
4413                            candidate_id,
4414                            &resolved_id,
4415                        );
4416                        return Ok(InferenceResult {
4417                            text: acc_text,
4418                            tool_calls: acc_tool_calls,
4419                            bounding_boxes: vec![],
4420                            trace_id: attempt_trace,
4421                            model_used: reported_model_used,
4422                            model_identity: bound_model_identity(
4423                                catalog_snapshot,
4424                                requested_model_id.as_deref(),
4425                                &resolved_id,
4426                            )?,
4427                            latency_ms: elapsed,
4428                            time_to_first_token_ms: None,
4429                            // Whatever the runner reported (#795); None when it
4430                            // reported nothing, rather than a fabricated zero.
4431                            usage: acc_usage,
4432                            provider_output_items: vec![],
4433                            // Streaming thinking capture is a follow-up (stream.rs
4434                            // would accumulate thinking blocks); empty for now.
4435                            thinking: vec![],
4436                            stop_reason: acc_stop_reason,
4437                            auth_fallback_from: auth_dead_lane.clone(),
4438                            local_last_resort,
4439                            fallback_from: fallback_hops.clone(),
4440                        });
4441                    }
4442                    Ok(Err(e)) => {
4443                        self.outcome_tracker
4444                            .write()
4445                            .await
4446                            .record_failure(&attempt_trace, &e.to_string());
4447                        // Same bookkeeping as the main per-candidate failure arm
4448                        // below: a REJECTED credential here is a dead lane, and a
4449                        // later candidate's success must be able to say so.
4450                        // Classified from the SAME value that becomes
4451                        // `last_error`, so both failure records and the error
4452                        // the caller sees cannot describe different failures.
4453                        let failed = InferenceError::InferenceFailed(e.to_string());
4454                        record_auth_dead_lane(
4455                            &mut auth_dead_lane,
4456                            &candidate_name,
4457                            &failed.to_string(),
4458                        );
4459                        record_route_credential_failure(
4460                            &mut route_credential_failure,
4461                            &candidate_name,
4462                            &failed,
4463                            was_primary && has_requested_route,
4464                        );
4465                        record_fallback_from(&mut fallback_hops, &candidate_name, &failed);
4466                        last_error = Some(failed);
4467                        continue;
4468                    }
4469                    Err(join_err) => {
4470                        let msg = format!("runner task panicked: {join_err}");
4471                        self.outcome_tracker
4472                            .write()
4473                            .await
4474                            .record_failure(&attempt_trace, &msg);
4475                        // Recorded too: a panicked runner skips this candidate
4476                        // and the chain proceeds to a different backbone, which
4477                        // is a transition like any other. The arm above records
4478                        // and this one did not — two adjacent skips, one
4479                        // bookkeeping (linus review, car#1351).
4480                        let failed = InferenceError::InferenceFailed(msg);
4481                        record_fallback_from(&mut fallback_hops, &candidate_name, &failed);
4482                        last_error = Some(failed);
4483                        continue;
4484                    }
4485                }
4486            }
4487
4488            let has_tools = Self::request_has_tools(&req);
4489
4490            // Reinforce done tool instructions in context (fixes #10: empty done results)
4491            let context = if has_tools
4492                && req.tools.as_ref().is_some_and(|t| {
4493                    t.iter().any(|tool| {
4494                        tool.get("function")
4495                            .and_then(|f| f.get("name"))
4496                            .and_then(|n| n.as_str())
4497                            == Some("done")
4498                    })
4499                }) {
4500                let base = req.context.as_deref().unwrap_or("");
4501                Some(format!(
4502                    "{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."
4503                ))
4504            } else {
4505                req.context.clone()
4506            };
4507
4508            // Only the remote path produces thinking blocks; capture them here
4509            // (the tuple below stays 5-element so no other arm changes) and read
4510            // them into the InferenceResult after the match. (F1.)
4511            let mut captured_thinking: Vec<crate::tasks::generate::ThinkingBlock> = Vec::new();
4512            let mut captured_provider_output_items: Vec<serde_json::Value> = Vec::new();
4513            // LOCAL_ADMISSION_BOUNDARY:adaptive-local-dispatch
4514            // Reserve before either the in-process loader or the daemon-owned
4515            // worker receives the request. Explicit selections fail here and
4516            // never silently substitute another model; adaptive routing may
4517            // skip a blocked local candidate and records the reason.
4518            let mut local_reservation = if !is_delegated {
4519                match schema
4520                    .as_ref()
4521                    .filter(|schema| Self::reserve_in_outer_dispatch(schema))
4522                {
4523                    Some(local_schema) => {
4524                        match self
4525                            .reserve_local_request_with_worker_retry(
4526                                local_schema,
4527                                estimated_footprint,
4528                            )
4529                            .await
4530                        {
4531                            Ok(reservation) => Some(reservation),
4532                            Err(error) if req.model.is_some() || req.params.strict_model => {
4533                                self.outcome_tracker
4534                                    .write()
4535                                    .await
4536                                    .record_capability_rejection(
4537                                        &attempt_trace,
4538                                        &error.to_string(),
4539                                    );
4540                                return Err(error);
4541                            }
4542                            Err(error) => {
4543                                tracing::warn!(
4544                                    model = %local_schema.id,
4545                                    error = %error,
4546                                    "adaptive local candidate blocked by resource policy; trying next route"
4547                                );
4548                                self.outcome_tracker
4549                                    .write()
4550                                    .await
4551                                    .record_capability_rejection(
4552                                        &attempt_trace,
4553                                        &error.to_string(),
4554                                    );
4555                                // A capability mismatch skips this candidate
4556                                // and the chain proceeds to a different
4557                                // backbone — a transition like any other, and
4558                                // one car#1351 names by hand.
4559                                record_fallback_from(&mut fallback_hops, &candidate_name, &error);
4560                                last_error = Some(error);
4561                                continue;
4562                            }
4563                        }
4564                    }
4565                    None => None,
4566                }
4567            } else {
4568                None
4569            };
4570            let result = if is_codex_cli {
4571                let schema_ref = schema
4572                    .as_ref()
4573                    .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4574                if req.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
4575                    Err(InferenceError::UnsupportedMode {
4576                        mode: "tools",
4577                        backend: "codex-cli",
4578                        reason: "the subscription-backed Codex source is a side-effect-free text generator and does not accept tools",
4579                    })
4580                } else if req
4581                    .messages
4582                    .as_ref()
4583                    .is_some_and(|messages| !messages.is_empty())
4584                {
4585                    Err(InferenceError::UnsupportedMode {
4586                        mode: "multi-turn-messages",
4587                        backend: "codex-cli",
4588                        reason: "the subscription-backed Codex source accepts one prompt plus optional context; it does not resume or replay conversations",
4589                    })
4590                } else if req.images.as_ref().is_some_and(|images| !images.is_empty())
4591                    || Self::request_has_video(&req)
4592                    || Self::request_has_audio(&req)
4593                {
4594                    Err(InferenceError::UnsupportedMode {
4595                        mode: "multimodal-content",
4596                        backend: "codex-cli",
4597                        reason: "the subscription-backed Codex source is text-only",
4598                    })
4599                } else if req.response_format.is_some() {
4600                    Err(InferenceError::UnsupportedMode {
4601                        mode: "response-format",
4602                        backend: "codex-cli",
4603                        reason: "the subscription-backed Codex source returns plain text and does not expose provider-enforced schemas",
4604                    })
4605                } else {
4606                    let model = match &schema_ref.source {
4607                        ModelSource::CodexCli { model } => model,
4608                        _ => unreachable!("is_codex_cli matched a different source"),
4609                    };
4610                    crate::backend::codex_cli::generate(
4611                        model,
4612                        &req.prompt,
4613                        context.as_deref(),
4614                        req.params.max_tokens,
4615                        schema_ref.context_length,
4616                    )
4617                    .await
4618                    .map(|output| (output.text, vec![], Some(output.usage), None, None))
4619                }
4620            } else if is_remote {
4621                // vllm-mlx: start + health-wait its supervised server, then route
4622                // to the live port. A startup failure is a per-candidate failure,
4623                // recorded like any other so the router can fall through.
4624                let (schema_val, _remote_request_reservation) = match self
4625                    .vllm_live_schema(
4626                        schema.unwrap(),
4627                        local_reservation.take(),
4628                        estimated_footprint,
4629                    )
4630                    .await
4631                {
4632                    Ok(pair) => pair,
4633                    Err(e) => {
4634                        self.outcome_tracker
4635                            .write()
4636                            .await
4637                            .record_failure(&attempt_trace, &e.to_string());
4638                        record_fallback_from(&mut fallback_hops, &candidate_name, &e);
4639                        last_error = Some(e);
4640                        continue;
4641                    }
4642                };
4643                let _ctx_len = schema_val.context_length;
4644                // Strip unsupported params based on model schema (#15).
4645                // Use -1.0 as sentinel: remote backends omit temperature entirely.
4646                let temperature = if !schema_val.supported_params.is_empty()
4647                    && !schema_val
4648                        .supported_params
4649                        .contains(&crate::schema::GenerateParam::Temperature)
4650                {
4651                    -1.0
4652                } else {
4653                    req.params.temperature
4654                };
4655
4656                // Always use the multi path so token usage is preserved on
4657                // both tool and non-tool requests. The bare `generate()` helper
4658                // in remote_backend wraps this same call but drops the usage
4659                // tuple, which breaks observability for plain text inference
4660                // (sc-3 in outcome 043).
4661                self.remote_backend
4662                    .generate_with_tools_multi_observed(
4663                        &schema_val,
4664                        &req.prompt,
4665                        context.as_deref(),
4666                        temperature,
4667                        req.params.max_tokens,
4668                        req.tools.as_deref(),
4669                        req.images.as_deref(),
4670                        req.messages.as_deref(),
4671                        req.params.tool_choice.as_deref(),
4672                        req.params.parallel_tool_calls,
4673                        req.params.budget_tokens,
4674                        req.cache_control,
4675                        req.params.cache_ttl,
4676                        req.context_stable_prefix.as_deref(),
4677                        req.response_format.as_ref(),
4678                        retry_observer,
4679                    )
4680                    .await
4681                    // Non-streaming remote APIs don't expose a
4682                    // first-token timestamp. Set TTFT=None and let
4683                    // streaming-aware callers measure it themselves
4684                    // via generate_tracked_stream. The 4th tuple element
4685                    // from generate_with_tools_multi is the provider stop_reason.
4686                    .map(|(t, c, thinking, provider_items, u, stop)| {
4687                        captured_thinking = thinking;
4688                        captured_provider_output_items = provider_items;
4689                        (t, c, u, None::<u64>, stop)
4690                    })
4691            } else if let Some(offload) = schema
4692                .as_ref()
4693                .filter(|schema| Self::supports_worker_offload(schema))
4694                .and_then(|_| crate::offload::current_local_offload())
4695            {
4696                // On-device generation is isolated in a worker subprocess
4697                // (car-releases#74): a large local MLX/Candle generation can
4698                // abort the process from the Metal/MLX C++ side, below every
4699                // Rust `catch_unwind`, taking the shared daemon down. When an
4700                // offloader is installed we hand it the fully-resolved request
4701                // instead of running the Metal decode loop here; a native abort
4702                // then kills only the worker (this returns `Err`, the daemon
4703                // fails one RPC and stays up, the next call respawns the worker).
4704                // Pin the model to the resolved id so the worker doesn't re-run
4705                // adaptive routing and land on a different backend.
4706                let mut offload_req = req.clone();
4707                offload_req.model = Some(resolved_id.clone());
4708                let schema_ref = schema
4709                    .as_ref()
4710                    .ok_or_else(|| InferenceError::ModelNotFound(resolved_id.clone()))?;
4711                let reservation = local_reservation.as_mut().ok_or_else(|| {
4712                    InferenceError::InferenceFailed(
4713                        "local worker dispatch missing admission reservation".into(),
4714                    )
4715                })?;
4716                if let Some(allocation_id) = offload.resident_allocation_id(&resolved_id) {
4717                    // A replacement offloader is a distinct process generation
4718                    // even when an older worker for the same logical model is
4719                    // still exiting. Reconcile against its exact owner before
4720                    // the child receives a load request so it cannot inherit a
4721                    // peer resident discount.
4722                    reservation.bind_allocation_id(&allocation_id);
4723                }
4724                let admission = self.prepare_worker_admission(schema_ref, reservation)?;
4725                match offload.generate_admitted(offload_req, admission).await {
4726                    Ok(outcome) => {
4727                        Self::reconcile_worker_residency(
4728                            offload.as_ref(),
4729                            &resolved_id,
4730                            &outcome.residency,
4731                            outcome.retention,
4732                            reservation,
4733                        )
4734                        .await?;
4735                        let ir = outcome.result;
4736                        // The worker already ran the full tracked generation
4737                        // (tool-call parsing, thinking capture, stop reason);
4738                        // adapt its InferenceResult into this arm's tuple and
4739                        // let the shared post-dispatch code (outcome tracking,
4740                        // grounding parse, InferenceResult assembly with the
4741                        // daemon-side trace_id/latency) run unchanged.
4742                        captured_thinking = ir.thinking;
4743                        captured_provider_output_items = ir.provider_output_items;
4744                        Ok((
4745                            ir.text,
4746                            ir.tool_calls,
4747                            ir.usage,
4748                            ir.time_to_first_token_ms,
4749                            ir.stop_reason,
4750                        ))
4751                    }
4752                    Err(e) => Err(e),
4753                }
4754            } else {
4755                #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4756                {
4757                    // On Apple Silicon, all local models must go through MLX.
4758                    // GGUF models were redirected to MLX equivalents above;
4759                    // if we still have a non-MLX model here, it has no MLX equivalent.
4760                    let schema_ref = schema
4761                        .as_ref()
4762                        .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4763
4764                    // Apple FoundationModels — on-device system model.
4765                    // Text generation, tool calling (capture-and-return
4766                    // bridge), and JsonSchema-constrained output are
4767                    // wired; vision/audio/video are rejected upstream
4768                    // (the public FM API is text-only) so the router
4769                    // falls through to a richer model rather than
4770                    // silently dropping capabilities.
4771                    if schema_ref.is_foundation_models() {
4772                        let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
4773                        if Self::request_has_video(&req) || Self::request_has_audio(&req) {
4774                            Err(InferenceError::UnsupportedMode {
4775                                mode: "multimodal-content",
4776                                backend: "foundation-models",
4777                                reason: "the FoundationModels bridge exposes text and image \
4778                                     input — route audio/video to a remote VL model",
4779                            })
4780                        } else if has_images {
4781                            // macOS 27's system model accepts images. Below that
4782                            // `generate_with_images` returns UnsupportedMode, which
4783                            // is a routing signal rather than a failure, so the
4784                            // request falls through to a richer model exactly as it
4785                            // did when this backend was text-only.
4786                            let images: Vec<String> = req
4787                                .images
4788                                .as_ref()
4789                                .map(|blocks| {
4790                                    blocks
4791                                        .iter()
4792                                        .filter_map(|b| match b {
4793                                            crate::ContentBlock::ImageBase64 { data, .. } => {
4794                                                Some(data.clone())
4795                                            }
4796                                            // Only inline bytes cross the bridge: a
4797                                            // URL would make the shim fetch, which is
4798                                            // the runtime's job, not the backend's.
4799                                            _ => None,
4800                                        })
4801                                        .collect()
4802                                })
4803                                .unwrap_or_default();
4804                            if images.is_empty() {
4805                                return Err(InferenceError::UnsupportedMode {
4806                                    mode: "multimodal-content",
4807                                    backend: "foundation-models",
4808                                    reason: "FoundationModels takes inline image bytes; \
4809                                         image URLs must be fetched by the caller first",
4810                                });
4811                            }
4812                            let prompt = req.prompt.clone();
4813                            let instructions = context.clone();
4814                            let fm_prompt = prompt.clone();
4815                            let fm_instructions = instructions.clone();
4816                            let fm_ctx_window = schema_ref.context_length as u64;
4817                            let max_tokens = req.params.max_tokens as u32;
4818                            let temperature = req.params.temperature;
4819                            tokio::task::spawn_blocking(move || {
4820                                crate::backend::foundation_models::generate_with_images(
4821                                    &prompt,
4822                                    instructions.as_deref(),
4823                                    &images,
4824                                    max_tokens,
4825                                    temperature as f32,
4826                                )
4827                            })
4828                            .await
4829                            .map_err(|e| {
4830                                InferenceError::InferenceFailed(format!(
4831                                    "FoundationModels task panicked: {e}"
4832                                ))
4833                            })
4834                            .and_then(|r| r)
4835                            .map(|text| {
4836                                let usage = foundation_models_usage(
4837                                    fm_instructions.as_deref(),
4838                                    &fm_prompt,
4839                                    &text,
4840                                    fm_ctx_window,
4841                                );
4842                                (text, vec![], usage, None, None)
4843                            })
4844                        } else if has_tools {
4845                            // One FM turn is either tool-enabled or
4846                            // schema-constrained, not both. Tools win;
4847                            // a JsonSchema response_format is dropped
4848                            // loudly (same policy as the Anthropic
4849                            // handler, which has no native field).
4850                            if req.response_format.is_some() {
4851                                tracing::warn!(
4852                                    "FoundationModels: response_format is ignored when tools \
4853                                     are present — one turn is either tool-enabled or \
4854                                     schema-constrained"
4855                                );
4856                            }
4857                            let prompt = req.prompt.clone();
4858                            let instructions = context.clone();
4859                            // Kept for post-hoc token counting: the originals move
4860                            // into the blocking closure below.
4861                            let fm_prompt = prompt.clone();
4862                            let fm_instructions = instructions.clone();
4863                            let fm_ctx_window = schema_ref.context_length as u64;
4864                            let tools_defs = req.tools.clone().unwrap_or_default();
4865                            let fm_tool_choice = req.params.tool_choice.clone();
4866                            let max_tokens = req.params.max_tokens as u32;
4867                            let temperature = req.params.temperature;
4868                            tokio::task::spawn_blocking(move || {
4869                                crate::backend::foundation_models::generate_with_tools(
4870                                    &prompt,
4871                                    instructions.as_deref(),
4872                                    &tools_defs,
4873                                    fm_tool_choice.as_deref(),
4874                                    max_tokens,
4875                                    temperature as f32,
4876                                )
4877                            })
4878                            .await
4879                            .map_err(|e| {
4880                                InferenceError::InferenceFailed(format!(
4881                                    "FoundationModels task panicked: {e}"
4882                                ))
4883                            })
4884                            .and_then(|r| r)
4885                            .map(|(text, calls)| {
4886                                let usage = foundation_models_usage(
4887                                    fm_instructions.as_deref(),
4888                                    &fm_prompt,
4889                                    &text,
4890                                    fm_ctx_window,
4891                                );
4892                                (text, calls, usage, None, None)
4893                            })
4894                        } else if let Some(crate::tasks::generate::ResponseFormat::JsonSchema {
4895                            schema,
4896                            ..
4897                        }) = &req.response_format
4898                        {
4899                            // Native constrained decoding via
4900                            // DynamicGenerationSchema — the framework
4901                            // enforces the schema, not the prompt.
4902                            let prompt = req.prompt.clone();
4903                            let instructions = context.clone();
4904                            // Kept for post-hoc token counting: the originals
4905                            // move into the blocking closure below.
4906                            let fm_prompt = prompt.clone();
4907                            let fm_instructions = instructions.clone();
4908                            let fm_ctx_window = schema_ref.context_length as u64;
4909                            let schema_val = schema.clone();
4910                            let max_tokens = req.params.max_tokens as u32;
4911                            let temperature = req.params.temperature;
4912                            tokio::task::spawn_blocking(move || {
4913                                crate::backend::foundation_models::generate_structured(
4914                                    &prompt,
4915                                    instructions.as_deref(),
4916                                    &schema_val,
4917                                    max_tokens,
4918                                    temperature as f32,
4919                                )
4920                            })
4921                            .await
4922                            .map_err(|e| {
4923                                InferenceError::InferenceFailed(format!(
4924                                    "FoundationModels task panicked: {e}"
4925                                ))
4926                            })
4927                            .and_then(|r| r)
4928                            .map(|text| {
4929                                let usage = foundation_models_usage(
4930                                    fm_instructions.as_deref(),
4931                                    &fm_prompt,
4932                                    &text,
4933                                    fm_ctx_window,
4934                                );
4935                                (text, vec![], usage, None, None)
4936                            })
4937                        } else {
4938                            // Plain text turn. JsonObject (schema-free
4939                            // JSON mode) has no native FM equivalent —
4940                            // enforce by instruction, loudly.
4941                            let instructions = if matches!(
4942                                req.response_format,
4943                                Some(crate::tasks::generate::ResponseFormat::JsonObject)
4944                            ) {
4945                                tracing::warn!(
4946                                    "FoundationModels: JsonObject response_format has no native \
4947                                     constrained mode — enforcing via instruction injection"
4948                                );
4949                                let base = context.clone().unwrap_or_default();
4950                                Some(format!(
4951                                    "{base}\n\nRespond with a single valid JSON object and \
4952                                     nothing else."
4953                                ))
4954                            } else {
4955                                context.clone()
4956                            };
4957                            let prompt = req.prompt.clone();
4958                            // Kept for post-hoc token counting: the originals
4959                            // move into the blocking closure below.
4960                            let fm_prompt = prompt.clone();
4961                            let fm_instructions = instructions.clone();
4962                            let fm_ctx_window = schema_ref.context_length as u64;
4963                            let max_tokens = req.params.max_tokens as u32;
4964                            let temperature = req.params.temperature;
4965                            tokio::task::spawn_blocking(move || {
4966                                crate::backend::foundation_models::generate(
4967                                    &prompt,
4968                                    instructions.as_deref(),
4969                                    max_tokens,
4970                                    temperature as f32,
4971                                )
4972                            })
4973                            .await
4974                            .map_err(|e| {
4975                                InferenceError::InferenceFailed(format!(
4976                                    "FoundationModels task panicked: {e}"
4977                                ))
4978                            })
4979                            .and_then(|r| r)
4980                            .map(|text| {
4981                                let usage = foundation_models_usage(
4982                                    fm_instructions.as_deref(),
4983                                    &fm_prompt,
4984                                    &text,
4985                                    fm_ctx_window,
4986                                );
4987                                (text, vec![], usage, None, None)
4988                            })
4989                        }
4990                    } else if !schema_ref.is_mlx() {
4991                        Err(InferenceError::InferenceFailed(format!(
4992                            "model '{}' has no MLX equivalent; Candle backend disabled on Apple Silicon",
4993                            schema_ref.id
4994                        )))
4995                    } else if schema_ref.tags.iter().any(|t| t == "mlx-vlm-cli") {
4996                        // Schemas explicitly tagged for the
4997                        // mlx-vlm CLI shell-out path skip the
4998                        // wasteful native-MLX text-tower load
4999                        // entirely — `mlx_vlm.generate` loads its
5000                        // own weights from the HF cache and
5001                        // performs vision tokenization that the
5002                        // native backend does not. Falls through
5003                        // to the same error message as the
5004                        // post-load fallback when mlx-vlm is not
5005                        // installed, so the user-facing failure
5006                        // is consistent.
5007                        let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
5008                        if !has_images {
5009                            return Err(InferenceError::UnsupportedMode {
5010                                mode: "text-only-on-mlx-vlm-id",
5011                                backend: "mlx-vlm-cli",
5012                                reason: "the `mlx-vlm/...` model IDs route exclusively \
5013                                     through the mlx-vlm CLI for image inference. \
5014                                     For text-only generation, route to a Qwen3 \
5015                                     text model (`mlx/qwen3-4b:4bit` etc.) — the \
5016                                     CLI shell-out has higher latency than the \
5017                                     in-process MLX text tower.",
5018                            });
5019                        }
5020                        let vlm_status = crate::backend::mlx_vlm_cli::runtime_status();
5021                        if !vlm_status.is_available() {
5022                            return Err(InferenceError::InferenceFailed(vlm_status.user_message()));
5023                        }
5024                        let model_dir = self.unified_registry.ensure_local(&schema_ref.id).await?;
5025                        let reservation = local_reservation
5026                            .as_mut()
5027                            .expect("local VLM branch has admission reservation");
5028                        Self::reconcile_transient_local_allocation(
5029                            reservation,
5030                            backend_cache::estimate_model_size(&model_dir),
5031                        )?;
5032                        let detached_lease = reservation.detached_lease();
5033                        let repo = match &schema_ref.source {
5034                            crate::schema::ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
5035                            _ => {
5036                                return Err(InferenceError::InferenceFailed(format!(
5037                                    "model '{}' is tagged mlx-vlm-cli but its \
5038                                     source isn't ModelSource::Mlx — registry bug",
5039                                    schema_ref.id
5040                                )));
5041                            }
5042                        };
5043                        let imgs = req.images.clone().unwrap_or_default();
5044                        let temp = req.params.temperature;
5045                        let max_t = req.params.max_tokens;
5046                        let prompt = req.prompt.clone();
5047                        let (text, cli_usage) = run_admitted_blocking(detached_lease, move || {
5048                            crate::backend::mlx_vlm_cli::generate(
5049                                &repo, &prompt, &imgs, temp, max_t,
5050                            )
5051                        })
5052                        .await
5053                        .map_err(|e| {
5054                            InferenceError::InferenceFailed(format!(
5055                                "mlx_vlm CLI task panicked: {e}"
5056                            ))
5057                        })??;
5058                        let bounding_boxes = parse_boxes(&text);
5059                        let latency_ms = start.elapsed().as_millis() as u64;
5060                        // mlx-vlm prints its own `Prompt:`/`Generation:` token
5061                        // counts and CAR used to discard them with the rest of
5062                        // the perf summary, hardcoding `usage: None`
5063                        // (Parslee-ai/car#795). They're worth recovering rather
5064                        // than estimating: the prompt count includes the image
5065                        // patches, which nothing on this side can reproduce —
5066                        // the vision tower lives in the Python process. Still
5067                        // `None` when the summary didn't parse; an absent count
5068                        // is honest, a zero is not.
5069                        let usage = cli_usage.map(|u| TokenUsage {
5070                            prompt_tokens: u.prompt_tokens,
5071                            completion_tokens: u.completion_tokens,
5072                            total_tokens: u.prompt_tokens + u.completion_tokens,
5073                            context_window: schema_ref.context_length as u64,
5074                            // Local in-process inference has no remote prompt cache.
5075                            ..Default::default()
5076                        });
5077                        {
5078                            // Real counts when the CLI reported them, else the
5079                            // word-count estimate — which still has to be
5080                            // non-zero, because a hardcoded 0 fails the #312
5081                            // mechanical-success gate in outcome::sweep_pending
5082                            // (same trap as the delegated-runner path above).
5083                            let (in_tokens, out_tokens) = match &usage {
5084                                Some(u) => (u.prompt_tokens as usize, u.completion_tokens as usize),
5085                                None => (estimated_input, text.split_whitespace().count()),
5086                            };
5087                            let mut tracker = self.outcome_tracker.write().await;
5088                            tracker.record_complete(
5089                                &attempt_trace,
5090                                latency_ms,
5091                                in_tokens,
5092                                out_tokens,
5093                            );
5094                        }
5095                        let local_last_resort = report_local_last_resort_served(
5096                            local_last_resort_id.as_deref(),
5097                            candidate_id,
5098                            &resolved_id,
5099                        );
5100                        return Ok(InferenceResult {
5101                            text,
5102                            tool_calls: vec![],
5103                            bounding_boxes,
5104                            trace_id: attempt_trace,
5105                            model_used: schema_ref.id.clone(),
5106                            model_identity: bound_model_identity(
5107                                catalog_snapshot,
5108                                requested_model_id.as_deref(),
5109                                &resolved_id,
5110                            )?,
5111                            latency_ms,
5112                            time_to_first_token_ms: None,
5113                            usage,
5114                            provider_output_items: Vec::new(),
5115                            thinking: Vec::new(), // local model — no thinking blocks
5116                            stop_reason: None,
5117                            auth_fallback_from: auth_dead_lane.clone(),
5118                            local_last_resort,
5119                            fallback_from: fallback_hops.clone(),
5120                        });
5121                    } else if !Self::supports_native_mlx(schema_ref) {
5122                        // A local MLX checkpoint the dedicated Qwen `MlxBackend`
5123                        // doesn't service (e.g. Gemma 4) routes through the
5124                        // polymorphic local-backend dispatch + shared decode
5125                        // loop. Text-only for now: reject multimodal content
5126                        // with a precise UnsupportedMode rather than silently
5127                        // dropping it.
5128                        if req.images.as_ref().is_some_and(|i| !i.is_empty())
5129                            || Self::request_has_video(&req)
5130                            || Self::request_has_audio(&req)
5131                        {
5132                            return Err(InferenceError::UnsupportedMode {
5133                                mode: "multimodal-content-block",
5134                                backend: "native-mlx-local",
5135                                reason: "this in-process MLX backend is text-only; route \
5136                                     image/video/audio understanding to a vLLM-MLX or remote \
5137                                     multimodal model",
5138                            });
5139                        }
5140                        // NB: do *not* pre-render with `render_for_local_backend`
5141                        // here. That helper flattens `messages`/`tools` into the
5142                        // Qwen3 wire format and clears the structured fields —
5143                        // correct for the native Qwen `MlxBackend`, fatal for a
5144                        // backend with its own chat template (Gemma 4 would then
5145                        // render a Qwen-formatted blob through the Gemma grammar
5146                        // and ramble). `generate_local` defers rendering to the
5147                        // backend's `render_prompt`, which sees the intact
5148                        // structured request (its own template, or the Qwen
5149                        // `render_chat_prompt` default for template-less backends).
5150                        self.generate_local(
5151                            req.clone(),
5152                            &schema_ref.id,
5153                            local_reservation
5154                                .as_mut()
5155                                .expect("local branch has admission reservation"),
5156                        )
5157                        .await
5158                    } else {
5159                        // Native MLX path doesn't have a video
5160                        // tokenization pipeline yet. Reject video
5161                        // content blocks up front with a precise
5162                        // UnsupportedMode so callers don't silently
5163                        // get a text-only reply.
5164                        if Self::request_has_video(&req) {
5165                            return Err(InferenceError::UnsupportedMode {
5166                                mode: "video-content-block",
5167                                backend: "native-mlx-qwen25vl",
5168                                reason: "Qwen2.5-VL video understanding is on the request surface \
5169                                     but the video-tokenization path (frame sampling + merger) \
5170                                     is not yet wired; route to a remote VL provider for now",
5171                            });
5172                        }
5173                        if Self::request_has_audio(&req) {
5174                            return Err(InferenceError::UnsupportedMode {
5175                                mode: "audio-content-block",
5176                                backend: "native-mlx-qwen25vl",
5177                                reason: "audio understanding is on the request surface (Gemma 4 \
5178                                     E2B/E4B and Gemini accept it) but the native MLX path \
5179                                     for this model does not — route to Gemini or Gemma-4",
5180                            });
5181                        }
5182                        let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
5183                        if has_images {
5184                            // Load the backend so we can ask it what it's
5185                            // actually able to execute. VL checkpoints currently
5186                            // load as text-only towers (see the `language_model.`
5187                            // prefix strip in backend/mlx.rs); the backend's
5188                            // `supports_capability(Vision)` returns false until
5189                            // GH #58 wires the vision tower. A registry-level
5190                            // capability claim is an aspiration for routing;
5191                            // the backend answer is the execution contract.
5192                            let (handle, _retention) = self
5193                                .ensure_mlx_backend(
5194                                    schema_ref,
5195                                    local_reservation
5196                                        .as_mut()
5197                                        .expect("local branch has admission reservation"),
5198                                )
5199                                .await?;
5200                            let can_do_vision = {
5201                                let guard = handle.lock().map_err(|_| {
5202                                    InferenceError::InferenceFailed(
5203                                        "MLX backend mutex poisoned".into(),
5204                                    )
5205                                })?;
5206                                guard.supports_capability(crate::schema::ModelCapability::Vision)
5207                            };
5208                            if !can_do_vision {
5209                                // The `mlx-vlm-cli`-tagged route handled
5210                                // above is the primary fix for #115; if
5211                                // we landed here it means the schema is
5212                                // a non-tagged `ModelSource::Mlx` (e.g.
5213                                // a user-registered custom model that
5214                                // doesn't advertise the CLI route). The
5215                                // error message points them at the
5216                                // tagged catalog IDs rather than
5217                                // claiming nothing local works.
5218                                return Err(InferenceError::UnsupportedMode {
5219                                    mode: "image-content-block",
5220                                    backend: "native-mlx-text",
5221                                    reason: "this MLX backend is a plain Qwen3 text tower. \
5222                                         For local image inference, route to \
5223                                         `mlx-vlm/qwen3-vl-2b:bf16` or another `mlx-vlm/...` \
5224                                         catalog ID so CAR shells out to `mlx_vlm.generate`. \
5225                                         Alternatives: a local vLLM-MLX VLM server, or a \
5226                                         remote VL model. (#115)",
5227                                });
5228                            }
5229                        }
5230                        self.generate_mlx(
5231                            render_for_local_backend(req.clone()),
5232                            &schema_ref.id,
5233                            local_reservation
5234                                .as_mut()
5235                                .expect("local branch has admission reservation"),
5236                        )
5237                        .await
5238                        .map(|(text, usage, ttft, stop)| (text, vec![], usage, ttft, stop))
5239                    }
5240                }
5241
5242                #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5243                {
5244                    let schema_ref = schema
5245                        .as_ref()
5246                        .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
5247                    match self
5248                        .ensure_backend(
5249                            schema_ref,
5250                            local_reservation
5251                                .as_mut()
5252                                .expect("local branch has admission reservation"),
5253                        )
5254                        .await
5255                    {
5256                        Ok(()) => {
5257                            let mut write = self.backend.write().await;
5258                            let backend = write.get_mut(&schema_ref.id).ok_or_else(|| {
5259                                InferenceError::InferenceFailed(format!(
5260                                    "candle backend missing after ensure_backend for {}",
5261                                    schema_ref.id
5262                                ))
5263                            })?;
5264                            // Real counts, not `None`. The candle loop knows
5265                            // both — post-truncation prompt length and the
5266                            // number of tokens it sampled — and discarding them
5267                            // made every local call on this platform report
5268                            // `usage: null` (Parslee-ai/car#795).
5269                            let ctx_window = backend.context_length().unwrap_or(0) as u64;
5270                            tasks::generate::generate(
5271                                backend,
5272                                render_for_local_backend(req.clone()),
5273                            )
5274                            .await
5275                            .map(
5276                                |(text, ttft, prompt_tokens, completion_tokens)| {
5277                                    let usage = TokenUsage {
5278                                        prompt_tokens: prompt_tokens as u64,
5279                                        completion_tokens: completion_tokens as u64,
5280                                        total_tokens: (prompt_tokens + completion_tokens) as u64,
5281                                        context_window: ctx_window,
5282                                        // Local in-process inference has no remote
5283                                        // prompt cache.
5284                                        ..Default::default()
5285                                    };
5286                                    (text, vec![], Some(usage), ttft, None)
5287                                },
5288                            )
5289                        }
5290                        Err(e) => Err(e),
5291                    }
5292                }
5293            };
5294
5295            match result {
5296                Ok((text, mut tool_calls, usage, time_to_first_token_ms, stop_reason)) => {
5297                    // In-process (MLX/candle) backends return prose only — they
5298                    // don't parse structured tool calls. When the caller asked
5299                    // for tools, recover any <tool_call> blocks the local model
5300                    // emitted in the text into structured tool_calls (and strip
5301                    // them from the visible text). Remote/delegated backends
5302                    // already populate tool_calls, so this is a no-op for them.
5303                    let text = if !is_remote
5304                        && !is_delegated
5305                        && req.tools.is_some()
5306                        && tool_calls.is_empty()
5307                    {
5308                        let (clean, parsed) = tasks::generate::parse_tool_calls(&text);
5309                        tool_calls = parsed;
5310                        clean
5311                    } else {
5312                        text
5313                    };
5314                    let latency_ms = start.elapsed().as_millis() as u64;
5315                    let estimated_tokens = usage
5316                        .as_ref()
5317                        .map(|u| u.completion_tokens as usize)
5318                        .unwrap_or_else(|| text.split_whitespace().count());
5319                    // Prefer the provider's real prompt-token count (remote);
5320                    // fall back to the router's pre-call estimate (local, where
5321                    // usage is None). Previously hardcoded 0, which zeroed
5322                    // total_input_tokens and corrupted quality_per_1k_tokens.
5323                    let input_tokens = usage
5324                        .as_ref()
5325                        .map(|u| u.prompt_tokens as usize)
5326                        .unwrap_or(estimated_input);
5327                    // Prompt-cache split (Anthropic): `input_tokens` above is
5328                    // the uncached prefix only, so carry the cached buckets
5329                    // separately for cache-aware cost accounting. Both 0 for
5330                    // providers/paths without prompt caching.
5331                    let (cache_read, cache_creation) = usage
5332                        .as_ref()
5333                        .map(|u| {
5334                            (
5335                                u.cache_read_input_tokens as usize,
5336                                u.cache_creation_input_tokens as usize,
5337                            )
5338                        })
5339                        .unwrap_or((0, 0));
5340                    {
5341                        let mut tracker = self.outcome_tracker.write().await;
5342                        tracker.record_complete_cached(
5343                            &attempt_trace,
5344                            latency_ms,
5345                            input_tokens,
5346                            estimated_tokens,
5347                            cache_read,
5348                            cache_creation,
5349                        );
5350                    }
5351                    // Circuit breaker: record success (#25). Key on the
5352                    // canonical `resolved_id` — the breaker's read side
5353                    // (`allow_request(&m.id)`, adaptive_router.rs) checks the
5354                    // canonical schema id, so booking under the raw alias here
5355                    // would mean the breaker never trips for aliased calls.
5356                    if let Ok(mut cb) = self.adaptive_router.circuit_breakers.lock() {
5357                        cb.record_success(&resolved_id);
5358                    }
5359                    // Auto-persist profiles after each successful call
5360                    self.auto_save_outcomes().await;
5361
5362                    // Record deferred span fields now that we have the result
5363                    let span = tracing::Span::current();
5364                    span.record("model", candidate_name.as_str());
5365                    span.record("latency_ms", latency_ms);
5366                    if let Some(ttft) = time_to_first_token_ms {
5367                        span.record("ttft_ms", ttft);
5368                    }
5369                    if let Some(ref u) = usage {
5370                        span.record("prompt_tokens", u.prompt_tokens);
5371                        span.record("completion_tokens", u.completion_tokens);
5372                    }
5373
5374                    // Parse Qwen2.5-VL grounding spans out of the
5375                    // text output. Empty vec on anything else.
5376                    let bounding_boxes = tasks::grounding::parse_boxes(&text);
5377                    let local_last_resort = report_local_last_resort_served(
5378                        local_last_resort_id.as_deref(),
5379                        candidate_id,
5380                        &resolved_id,
5381                    );
5382                    return Ok(InferenceResult {
5383                        text,
5384                        tool_calls,
5385                        bounding_boxes,
5386                        trace_id: attempt_trace,
5387                        model_used: reported_model_used,
5388                        model_identity: bound_model_identity(
5389                            catalog_snapshot,
5390                            requested_model_id.as_deref(),
5391                            &resolved_id,
5392                        )?,
5393                        latency_ms,
5394                        time_to_first_token_ms,
5395                        usage,
5396                        provider_output_items: captured_provider_output_items,
5397                        thinking: captured_thinking,
5398                        stop_reason,
5399                        auth_fallback_from: auth_dead_lane.clone(),
5400                        local_last_resort,
5401                        fallback_from: fallback_hops.clone(),
5402                    });
5403                }
5404                Err(e) => {
5405                    if matches!(e, InferenceError::ControlledTermination) {
5406                        self.outcome_tracker
5407                            .write()
5408                            .await
5409                            .record_capability_rejection(&attempt_trace, &e.to_string());
5410                        return Err(e);
5411                    }
5412                    tracing::warn!(
5413                        model = %candidate_name,
5414                        error = %e,
5415                        remaining = candidate_queue.len(),
5416                        "model failed, trying next fallback immediately"
5417                    );
5418                    // A candidate whose CREDENTIAL was rejected is a dead lane,
5419                    // not a flaky one: retrying cannot help and the human has to
5420                    // sign in. Remember the first such lane so a later
5421                    // candidate's success can announce that it degraded off it
5422                    // instead of silently serving a different model
5423                    // (Parslee-ai/car#888).
5424                    record_auth_dead_lane(&mut auth_dead_lane, &candidate_name, &e.to_string());
5425                    record_route_credential_failure(
5426                        &mut route_credential_failure,
5427                        &candidate_name,
5428                        &e,
5429                        was_primary && has_requested_route,
5430                    );
5431                    record_fallback_from(&mut fallback_hops, &candidate_name, &e);
5432                    // Resolve every attempt exactly once. A deterministic
5433                    // request/provider capability mismatch is visible in the
5434                    // receipt ledger but must not degrade the model's generic
5435                    // health or answer-quality profile for unrelated traffic.
5436                    {
5437                        let mut tracker = self.outcome_tracker.write().await;
5438                        record_dispatch_failure(&mut tracker, &attempt_trace, &e);
5439                    }
5440                    // Circuit breaker: record failure (#25).
5441                    // 4xx errors (client errors) use longer cooldown since they indicate
5442                    // permanent incompatibility (wrong endpoint, unsupported param).
5443                    // EXCEPTION: `UnsupportedMode` is a deterministic capability
5444                    // mismatch (e.g. JsonSchema on Anthropic, video on a text-only
5445                    // provider) — see `error_counts_against_circuit_breaker`. The
5446                    // The outcome trace above is recorded as a capability
5447                    // rejection (not a profile failure), and the fallback loop
5448                    // still advances to a model that supports the mode.
5449                    if error_counts_against_circuit_breaker(&e) {
5450                        let err_str = e.to_string();
5451                        let is_client_error =
5452                            err_str.contains("API returned 4") && !err_str.contains("429");
5453                        if let Ok(mut cb) = self.adaptive_router.circuit_breakers.lock() {
5454                            // Canonical `resolved_id` — see the success path above.
5455                            cb.record_failure(&resolved_id);
5456                            // For persistent 4xx errors, lower the threshold by
5457                            // recording an extra failure to trip faster
5458                            if is_client_error {
5459                                cb.record_failure(&resolved_id);
5460                            }
5461                        }
5462                    }
5463                    // Reset backend so next model can load
5464                    #[cfg(not(all(
5465                        target_os = "macos",
5466                        target_arch = "aarch64",
5467                        not(car_skip_mlx)
5468                    )))]
5469                    {
5470                        let mut write = self.backend.write().await;
5471                        if write.remove(&resolved_id).is_some() {
5472                            self.local_admission.mark_evicted(&resolved_id);
5473                        }
5474                    }
5475                    // I4 provider failover: when the PRIMARY fails with a
5476                    // transient provider-side error (5xx/429/timeout), a
5477                    // same-provider sibling is likely down too — promote
5478                    // the first CROSS-provider fallback to the queue
5479                    // front. Permanent errors (auth, bad request) keep the
5480                    // router's original order: they're caller-shaped, not
5481                    // provider-shaped.
5482                    // An account rejection is true of every model on that
5483                    // account, so trying the rest of its candidates NEXT just
5484                    // replays the identical 401/402 — latency for nothing, and
5485                    // a pile of duplicate receipts. Send them to the back of
5486                    // the chain so another provider is tried first
5487                    // (Parslee-ai/car#650).
5488                    //
5489                    // Demoted, not dropped: soft like every other constraint on
5490                    // this path. Two credentials can share one provider label
5491                    // (per-model `api_key_env`, a multi-key pool), so a hard
5492                    // drop could remove the chain's last working option.
5493                    // An unconfigured gateway namespace is stronger than an
5494                    // account rejection: the deployment has NO upstream to
5495                    // proxy to, so every remaining alias under the prefix is
5496                    // certain to fail, not merely likely. Drop them outright
5497                    // rather than demoting — demotion is the right hedge for
5498                    // ProviderAccount, where two credentials can share one
5499                    // provider label and the chain's last working option might
5500                    // sit behind it, but here the prefix IS the condition's
5501                    // scope and nothing under it can differ (car#786).
5502                    if let InferenceError::GatewayUnconfigured { namespace, .. } = &e {
5503                        let before = candidate_queue.len();
5504                        candidate_queue.retain(|id| !id.starts_with(namespace.as_str()));
5505                        let dropped = before - candidate_queue.len();
5506                        if dropped > 0 {
5507                            tracing::info!(
5508                                %namespace,
5509                                dropped,
5510                                remaining = candidate_queue.len(),
5511                                "gateway has no upstream for this namespace; dropping its \
5512                                 remaining candidates instead of replaying the same rejection"
5513                            );
5514                        }
5515                    }
5516                    if error_ends_fallback_chain(&e) {
5517                        let dropped = candidate_queue.len();
5518                        candidate_queue.clear();
5519                        if dropped > 0 {
5520                            tracing::info!(
5521                                dropped,
5522                                "content refused for this request; ending the fallback chain \
5523                                 rather than answering with a different model"
5524                            );
5525                        }
5526                    }
5527                    if let InferenceError::ProviderAccount { provider, .. } = &e {
5528                        // Resolve the provider from the REGISTRY, not a string
5529                        // split on the id — same reasoning as the cross-provider
5530                        // promotion below (linus review #4 on I4).
5531                        let mut rest: Vec<String> = candidate_queue.iter().cloned().collect();
5532                        let demoted = routing_ext::demote_provider(provider, &mut rest, |id| {
5533                            routing_registry
5534                                .get(id)
5535                                .or_else(|| routing_registry.find_by_name(id))
5536                                .map(|s| s.provider.clone())
5537                        });
5538                        candidate_queue = rest.into();
5539                        if demoted > 0 {
5540                            tracing::info!(
5541                                %provider,
5542                                demoted,
5543                                remaining = candidate_queue.len(),
5544                                "account-level rejection; deferring this provider's \
5545                                 remaining candidates to the end of the chain"
5546                            );
5547                        }
5548                    }
5549                    if was_primary && !candidate_queue.is_empty() {
5550                        let err_str = e.to_string();
5551                        // Recover the HTTP status the remote backend baked
5552                        // into "API returned <status>: <body>" so a body
5553                        // that merely QUOTES a transient phrase (e.g. a 400
5554                        // whose message says "timeout param invalid") can't
5555                        // classify as transient (linus review #3). Typed
5556                        // error plumbing is the named follow-up.
5557                        let status = parse_api_returned_status(&err_str);
5558                        if routing_ext::is_provider_transient(status, &err_str) {
5559                            // Resolve provider from the REGISTRY, not a
5560                            // string split — slashless aliases would make
5561                            // every candidate look cross-provider (linus
5562                            // review #4).
5563                            let provider_of = |id: &str| {
5564                                routing_registry
5565                                    .get(id)
5566                                    .or_else(|| routing_registry.find_by_name(id))
5567                                    .map(|s| s.provider.clone())
5568                            };
5569                            let primary = provider_of(candidate_id).unwrap_or_default();
5570                            let queue_vec: Vec<String> = candidate_queue.iter().cloned().collect();
5571                            if let Some(cross) =
5572                                routing_ext::first_cross_provider(&primary, &queue_vec, provider_of)
5573                            {
5574                                let cross = cross.to_string();
5575                                if let Some(pos) = candidate_queue.iter().position(|m| *m == cross)
5576                                {
5577                                    if pos > 0 {
5578                                        if let Some(m) = candidate_queue.remove(pos) {
5579                                            tracing::info!(
5580                                                promoted = %m,
5581                                                "transient provider error on primary; promoting cross-provider fallback"
5582                                            );
5583                                            candidate_queue.push_front(m);
5584                                        }
5585                                    }
5586                                }
5587                            }
5588                        }
5589                    }
5590                    last_error = Some(e);
5591                }
5592            }
5593        }
5594
5595        // All models failed.
5596        let underlying = last_error.unwrap_or(InferenceError::InferenceFailed(
5597            "no models available".into(),
5598        ));
5599
5600        // A fresh install with no Parslee auth exhausts the entire
5601        // fallback chain and surfaces an opaque "no credential for
5602        // proprietary provider 'parslee'" with zero recovery guidance —
5603        // the #231 §7.1 DX failure (acute on Windows, which has no MLX
5604        // path and no bundled local model). When the exhaustion is a
5605        // missing-backend/credential case, wrap it with the two
5606        // concrete recovery paths; other errors pass through unchanged
5607        // so a genuine 500/timeout/429 isn't buried under setup advice.
5608        // Classify the exhaustion: a never-signed-in / no-backend case gets the
5609        // setup hint; an expired-credential (auth-rejection) exhaustion gets the
5610        // re-authenticate hint — otherwise a 401 from a lapsed Parslee session
5611        // surfaced verbatim as a raw HTTP status with no guidance. A genuine
5612        // 500/timeout/429 matches neither and passes through unchanged.
5613        let e = apply_route_failure_context(underlying, route_credential_failure.as_ref());
5614        // Each candidate already recorded its own failure against its
5615        // per-attempt trace inside the loop, so there is nothing to record
5616        // here — doing so was the source of the fail_count > total_calls skew.
5617        self.auto_save_outcomes().await;
5618        Err(e)
5619    }
5620
5621    /// Internal producer for [`generate_tracked_stream`]: resolves the model,
5622    /// spawns the backend generation task, and returns the resolved model id
5623    /// alongside the event receiver. The public wrapper taps this stream to
5624    /// record an outcome when it finishes.
5625    async fn generate_stream_raw(
5626        &self,
5627        req: GenerateRequest,
5628    ) -> Result<(String, tokio::sync::mpsc::Receiver<stream::StreamEvent>), InferenceError> {
5629        let routing_registry = self
5630            .request_routing_registry_snapshot(req.model.as_deref())
5631            .await;
5632        if let Some(requested) = req.model.as_deref() {
5633            if routing_registry
5634                .get(requested)
5635                .or_else(|| routing_registry.find_by_name(requested))
5636                .is_none()
5637            {
5638                return Err(InferenceError::ModelNotFound(requested.to_string()));
5639            }
5640        }
5641        let has_tools = Self::request_has_tools(&req);
5642        let has_vision = Self::request_needs_vision(&req);
5643        let (estimated_input, _, _) = self.estimated_tokens(&req, None);
5644        let estimated_footprint = estimated_input.saturating_add(req.params.max_tokens);
5645        let (estimated_cache_read, estimated_cache_write) =
5646            Self::routing_cache_estimates(&req, estimated_input);
5647        let preferred_model = self
5648            .preferred_model_for_capability(ModelCapability::Generate)
5649            .map(str::to_string);
5650        let exclude_set = self
5651            .adaptive_router
5652            .build_exclude_set(req.intent.as_ref(), &routing_registry);
5653        let unpinned_override = self
5654            .lane_pin_for(&req, &routing_registry)
5655            .or(preferred_model)
5656            .filter(|model| !Self::model_is_excluded(&exclude_set, &routing_registry, model));
5657        let decision = match req.model.clone().or(unpinned_override) {
5658            Some(m) => {
5659                let ctx_len = routing_registry
5660                    .get(&m)
5661                    .or_else(|| routing_registry.find_by_name(&m))
5662                    .map(|s| s.context_length)
5663                    .unwrap_or(0);
5664                AdaptiveRoutingDecision {
5665                    model_id: m.clone(),
5666                    model_name: m,
5667                    task: InferenceTask::Generate,
5668                    complexity: TaskComplexity::assess(&req.prompt),
5669                    reason: "explicit model".into(),
5670                    strategy: RoutingStrategy::Explicit,
5671                    predicted_quality: 0.5,
5672                    fallbacks: vec![],
5673                    context_length: ctx_len,
5674                    needs_compaction: false,
5675                    candidates: vec![],
5676                }
5677            }
5678            None => {
5679                let tracker_read = self.outcome_tracker.read().await;
5680                self.adaptive_router
5681                    .route_with(crate::adaptive_router::RouteRequest {
5682                        estimated_total_tokens: estimated_footprint,
5683                        estimated_input_tokens: estimated_input,
5684                        estimated_output_tokens: req.params.max_tokens,
5685                        estimated_cache_read_tokens: estimated_cache_read,
5686                        estimated_cache_write_tokens: estimated_cache_write,
5687                        has_tools,
5688                        has_vision,
5689                        workload: req.params.workload,
5690                        intent: req.intent.as_ref(),
5691                        ..crate::adaptive_router::RouteRequest::new(
5692                            &req.prompt,
5693                            &routing_registry,
5694                            &tracker_read,
5695                        )
5696                    })
5697            }
5698        };
5699
5700        if decision.model_id.is_empty() {
5701            let excluded_models = req
5702                .intent
5703                .as_ref()
5704                .map(|hint| hint.exclude_models.join(", "))
5705                .unwrap_or_default();
5706            return Err(InferenceError::NoEligibleModel { excluded_models });
5707        }
5708
5709        // `mut` is needed on the aarch64-macos cfg branch below;
5710        // other targets don't rebind.
5711        #[allow(unused_mut)]
5712        let mut schema = routing_registry
5713            .get(&decision.model_id)
5714            .or_else(|| routing_registry.find_by_name(&decision.model_id))
5715            .cloned();
5716
5717        // On Apple Silicon, redirect GGUF/Candle models to their MLX equivalents.
5718        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
5719        if let Some(ref s) = schema {
5720            if let Some(mlx_equiv) = routing_registry.resolve_mlx_equivalent(s) {
5721                tracing::info!(
5722                    from = %s.id, to = %mlx_equiv.id,
5723                    "redirecting GGUF model to MLX equivalent on Apple Silicon (stream)"
5724                );
5725                schema = Some(mlx_equiv.clone());
5726            }
5727        }
5728
5729        // Tool-capability guard (honest routing) — streaming parity with the
5730        // non-streaming generate path. A tools-bearing request that resolved to
5731        // a backend which can't parse tool calls (e.g. the in-process
5732        // mlx/candle path) would otherwise stream prose and silently drop the
5733        // tools. Fail clearly instead.
5734        if has_tools
5735            && schema
5736                .as_ref()
5737                .map(|s| !s.has_capability(ModelCapability::ToolUse))
5738                .unwrap_or(false)
5739        {
5740            let backend = schema
5741                .as_ref()
5742                .map(|s| if s.is_local() { "local" } else { "remote" })
5743                .unwrap_or("unknown");
5744            return Err(InferenceError::UnsupportedMode {
5745                mode: "tool_use",
5746                backend,
5747                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)",
5748            });
5749        }
5750
5751        // Identity the tap records the outcome against (post-MLX-redirect).
5752        let resolved_model_id = schema
5753            .as_ref()
5754            .map(|s| s.id.clone())
5755            .unwrap_or_else(|| decision.model_id.clone());
5756
5757        // Default per-turn output budget from the resolved model when the
5758        // caller left it at the library default (4096) — mirrors the
5759        // non-streaming generate_tracked path so streamed long-horizon tool_use
5760        // JSON isn't truncated at 4096 either, and so an in-process model does
5761        // not silently inherit a 32768-token (== tens of minutes) budget here
5762        // after that was fixed on the non-streaming path (car#851).
5763        let mut req = req;
5764        if let Some(schema) = schema.as_ref() {
5765            req.params.max_tokens = resolved_max_tokens(req.params.max_tokens, schema);
5766        }
5767
5768        let is_remote = schema
5769            .as_ref()
5770            .map(|s| s.is_remote() || s.is_vllm_mlx())
5771            .unwrap_or(false);
5772
5773        let is_codex_cli = schema.as_ref().map(|s| s.is_codex_cli()).unwrap_or(false);
5774        let is_delegated = schema.as_ref().map(|s| s.is_delegated()).unwrap_or(false);
5775
5776        if is_codex_cli {
5777            return Err(InferenceError::UnsupportedMode {
5778                mode: "streaming",
5779                backend: "codex-cli",
5780                reason: "codex exec reports final answer items rather than token deltas; use non-streaming infer/generate_tracked",
5781            });
5782        }
5783
5784        if is_delegated {
5785            // Closes Parslee-ai/car-releases#24. The host owns the
5786            // wire format; CAR just plays back the events the runner
5787            // emits and stays in the policy/replay path.
5788            let runner = runner::current_inference_runner().ok_or_else(|| {
5789                InferenceError::InferenceFailed(
5790                    "model declares ModelSource::Delegated but no inference runner is registered \
5791                     (call set_inference_runner / registerInferenceRunner / register_inference_runner)"
5792                        .into(),
5793                )
5794            })?;
5795            let (tx, rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
5796            let emitter = runner::EventEmitter::new(tx);
5797            let request = req.clone();
5798            tokio::spawn(async move {
5799                if let Err(e) = runner.run(request, emitter).await {
5800                    tracing::warn!(error = %e, "delegated inference runner failed");
5801                }
5802            });
5803            return Ok((resolved_model_id, rx));
5804        }
5805
5806        // LOCAL_ADMISSION_BOUNDARY:stream-local-dispatch
5807        let mut local_reservation = if !is_remote
5808            || schema.as_ref().is_some_and(ModelSchema::is_vllm_mlx)
5809        {
5810            if let Some(local_schema) = schema
5811                .as_ref()
5812                .filter(|schema| Self::reserve_in_outer_dispatch(schema))
5813            {
5814                Some(
5815                    self.reserve_local_request_with_worker_retry(local_schema, estimated_footprint)
5816                        .await?,
5817                )
5818            } else {
5819                None
5820            }
5821        } else {
5822            None
5823        };
5824
5825        // On-device streaming isolated in a worker subprocess (car-releases#74)
5826        // — the streaming mirror of the non-streaming offload in
5827        // `generate_tracked_inner`. Only local models route here; a remote HTTP
5828        // stream can't abort the process, so it stays in-daemon. A mid-stream
5829        // worker death drops the sender, which the caller sees as a normal
5830        // stream end (the accumulator surfaces whatever arrived).
5831        if !is_remote && schema.as_ref().is_some_and(Self::supports_worker_offload) {
5832            if let Some(offload) = crate::offload::current_local_offload() {
5833                let mut offload_req = req.clone();
5834                offload_req.model = Some(resolved_model_id.clone());
5835                let schema_ref = schema
5836                    .as_ref()
5837                    .ok_or_else(|| InferenceError::ModelNotFound(resolved_model_id.clone()))?;
5838                let reservation = local_reservation.as_mut().ok_or_else(|| {
5839                    InferenceError::InferenceFailed(
5840                        "local worker stream missing admission reservation".into(),
5841                    )
5842                })?;
5843                if let Some(allocation_id) = offload.resident_allocation_id(&resolved_model_id) {
5844                    reservation.bind_allocation_id(&allocation_id);
5845                }
5846                let admission = self.prepare_worker_admission(schema_ref, reservation)?;
5847                let offload_stream = offload.stream_admitted(offload_req, admission).await?;
5848                Self::reconcile_worker_residency(
5849                    offload.as_ref(),
5850                    &resolved_model_id,
5851                    &offload_stream.residency,
5852                    offload_stream.retention,
5853                    reservation,
5854                )
5855                .await?;
5856                let rx = offload_stream.events;
5857                let rx = match local_reservation {
5858                    Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
5859                    None => rx,
5860                };
5861                return Ok((resolved_model_id, rx));
5862            }
5863        }
5864
5865        if is_remote {
5866            let mut candidates = vec![schema.unwrap()];
5867            let mut local_fallback_ids = Vec::new();
5868            for fallback_id in &decision.fallbacks {
5869                if let Some(fallback) = routing_registry
5870                    .get(fallback_id)
5871                    .or_else(|| routing_registry.find_by_name(fallback_id))
5872                {
5873                    if (fallback.is_remote() || fallback.is_vllm_mlx())
5874                        && (!has_tools || fallback.has_capability(ModelCapability::ToolUse))
5875                        && (!has_vision || fallback.has_capability(ModelCapability::Vision))
5876                        && !candidates
5877                            .iter()
5878                            .any(|candidate| candidate.id == fallback.id)
5879                    {
5880                        candidates.push(fallback.clone());
5881                    } else if fallback.is_local()
5882                        && !fallback.is_vllm_mlx()
5883                        && (!has_tools || fallback.has_capability(ModelCapability::ToolUse))
5884                        && (!has_vision || fallback.has_capability(ModelCapability::Vision))
5885                    {
5886                        local_fallback_ids.push(fallback.id.clone());
5887                    }
5888                }
5889            }
5890            let mut last_error = None;
5891            for candidate in candidates {
5892                // For a vllm-mlx model this starts (and health-waits) its
5893                // supervised server and rewrites the endpoint to the live port.
5894                let (candidate, mut candidate_reservation) = match self
5895                    .vllm_live_schema(candidate, local_reservation.take(), estimated_footprint)
5896                    .await
5897                {
5898                    Ok(candidate) => candidate,
5899                    Err(error) => {
5900                        last_error = Some(error);
5901                        continue;
5902                    }
5903                };
5904                self.remote_backend.register_model_keys(&candidate).await;
5905
5906                let spend_guard = self
5907                    .spend_limits
5908                    .read()
5909                    .unwrap()
5910                    .as_ref()
5911                    .and_then(|limits| limits.per_request_usd)
5912                    .map(|budget| {
5913                        let mut prompt_tokens =
5914                            routing_ext::MidStreamSpendGuard::estimate_tokens(&req.prompt)
5915                                + req
5916                                    .context
5917                                    .as_deref()
5918                                    .map(routing_ext::MidStreamSpendGuard::estimate_tokens)
5919                                    .unwrap_or(0);
5920                        prompt_tokens += media_tokens::request_media_and_history_tokens(
5921                            req.images.as_deref(),
5922                            req.messages.as_deref(),
5923                        ) as u64;
5924                        if let Some(tools) = &req.tools {
5925                            prompt_tokens += routing_ext::MidStreamSpendGuard::estimate_tokens(
5926                                &serde_json::to_string(tools).unwrap_or_default(),
5927                            );
5928                        }
5929                        let prices = candidate.cost.prices_for(prompt_tokens as usize);
5930                        let input_price = prices
5931                            .input_per_mtok
5932                            .map(|c| c / 1_000_000.0)
5933                            .unwrap_or(0.0);
5934                        let output_price = prices
5935                            .output_per_mtok
5936                            .map(|c| c / 1_000_000.0)
5937                            .unwrap_or(0.0);
5938                        routing_ext::MidStreamSpendGuard::new(
5939                            Some(budget),
5940                            prompt_tokens as f64 * input_price,
5941                            input_price,
5942                            output_price,
5943                        )
5944                    });
5945
5946                match self
5947                    .remote_backend
5948                    .generate_stream(
5949                        &candidate,
5950                        &req.prompt,
5951                        req.messages.as_deref(),
5952                        req.context.as_deref(),
5953                        req.params.temperature,
5954                        req.params.max_tokens,
5955                        req.tools.as_deref(),
5956                        req.images.as_deref(),
5957                        req.params.tool_choice.as_deref(),
5958                        req.params.parallel_tool_calls,
5959                        req.response_format.as_ref(),
5960                        spend_guard,
5961                    )
5962                    .await
5963                {
5964                    Ok(receiver) => {
5965                        let receiver = Self::hold_optional_reservation_for_stream(
5966                            receiver,
5967                            candidate_reservation.take(),
5968                        );
5969                        return Ok((candidate.id, receiver));
5970                    }
5971                    Err(error) => {
5972                        tracing::warn!(
5973                            model = %candidate.id,
5974                            %error,
5975                            "remote stream setup failed; trying routed fallback"
5976                        );
5977                        last_error = Some(error);
5978                    }
5979                }
5980            }
5981
5982            // Remote-primary streaming must retain compatible local candidates
5983            // and re-enter the ordinary dispatcher for them. That preserves
5984            // offload/FoundationModels/native behavior instead of duplicating
5985            // a partial local backend path in this branch.
5986            if !req.params.strict_model {
5987                if let Some(local) = self.first_installed_local_model(has_tools) {
5988                    let schema = routing_registry
5989                        .get(&local)
5990                        .or_else(|| routing_registry.find_by_name(&local));
5991                    let supports_request = schema.is_some_and(|schema| {
5992                        !has_vision || schema.has_capability(ModelCapability::Vision)
5993                    });
5994                    if supports_request
5995                        && !local_fallback_ids.iter().any(|candidate| {
5996                            routing_registry
5997                                .get(candidate)
5998                                .or_else(|| routing_registry.find_by_name(candidate))
5999                                .is_some_and(|schema| schema.id == local || schema.name == local)
6000                        })
6001                    {
6002                        local_fallback_ids.push(local);
6003                    }
6004                }
6005                for local_id in local_fallback_ids {
6006                    let mut fallback_req = req.clone();
6007                    fallback_req.model = Some(local_id);
6008                    fallback_req.params.strict_model = true;
6009                    match Box::pin(self.generate_stream_raw(fallback_req)).await {
6010                        Ok(stream) => return Ok(stream),
6011                        Err(error) => {
6012                            tracing::warn!(%error, "local streaming fallback failed");
6013                            last_error = Some(error);
6014                        }
6015                    }
6016                }
6017            }
6018
6019            Err(last_error.unwrap_or_else(|| {
6020                InferenceError::InferenceFailed(
6021                    "no compatible remote streaming model available".to_string(),
6022                )
6023            }))
6024        } else {
6025            let schema =
6026                schema.ok_or_else(|| InferenceError::ModelNotFound(decision.model_id.clone()))?;
6027            let (tx, rx) = tokio::sync::mpsc::channel(64);
6028
6029            // FoundationModels streaming dispatch — reachable on the
6030            // Apple-aarch64 targets where build.rs compiles the shim
6031            // (macOS, iOS device, iOS simulator on Apple Silicon).
6032            // Split out from the MLX block below because MLX is
6033            // macOS-only — `mlx-rs` can't cross-compile for iOS.
6034            #[cfg(any(
6035                all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6036                all(target_os = "ios", target_arch = "aarch64")
6037            ))]
6038            {
6039                if schema.is_foundation_models() {
6040                    // Same text-only boundary as the non-streaming FM
6041                    // branch: multimodal blocks error out instead of
6042                    // being silently dropped by the text-only bridge.
6043                    if Self::request_has_video(&req)
6044                        || Self::request_has_audio(&req)
6045                        || req.images.as_ref().is_some_and(|imgs| !imgs.is_empty())
6046                    {
6047                        return Err(InferenceError::UnsupportedMode {
6048                            mode: "multimodal-content",
6049                            backend: "foundation-models",
6050                            reason: "the FoundationModels bridge currently exposes text-only \
6051                                 generation — route image/audio/video to a remote VL model",
6052                        });
6053                    }
6054                    // Tool-enabled streaming: FM tool capture is a
6055                    // blocking round-trip (the framework invokes the
6056                    // capture tool mid-turn), so run the blocking
6057                    // bridge and emit the outcome as one TextDelta +
6058                    // Done{tool_calls} — same events, coarser grain.
6059                    if let Some(tools_defs) = req.tools.clone().filter(|t| !t.is_empty()) {
6060                        let prompt = req.prompt.clone();
6061                        let instructions = req.context.clone();
6062                        let fm_tool_choice = req.params.tool_choice.clone();
6063                        let max_tokens = req.params.max_tokens as u32;
6064                        let temperature = req.params.temperature;
6065                        tokio::task::spawn_blocking(move || {
6066                            match crate::backend::foundation_models::generate_with_tools(
6067                                &prompt,
6068                                instructions.as_deref(),
6069                                &tools_defs,
6070                                fm_tool_choice.as_deref(),
6071                                max_tokens,
6072                                temperature as f32,
6073                            ) {
6074                                Ok((text, tool_calls)) => {
6075                                    if !text.is_empty() {
6076                                        let _ = tx.blocking_send(stream::StreamEvent::TextDelta(
6077                                            text.clone(),
6078                                        ));
6079                                    }
6080                                    let _ = tx.blocking_send(stream::StreamEvent::Done {
6081                                        text,
6082                                        tool_calls,
6083                                    });
6084                                }
6085                                Err(e) => {
6086                                    // Match the MLX streaming error
6087                                    // convention: log and drop the
6088                                    // sender so the channel closes
6089                                    // without a Done event.
6090                                    tracing::warn!(
6091                                        error = %e,
6092                                        "FoundationModels tool-enabled stream failed"
6093                                    );
6094                                }
6095                            }
6096                        });
6097                        return Ok((resolved_model_id, rx));
6098                    }
6099                    let prompt = req.prompt.clone();
6100                    let instructions = req.context.clone();
6101                    let max_tokens = req.params.max_tokens as u32;
6102                    let temperature = req.params.temperature;
6103                    let tx_clone = tx.clone();
6104                    tokio::task::spawn_blocking(move || {
6105                        // Share the accumulator between the streaming
6106                        // callback and the post-stream Done event so
6107                        // the FoundationModels path matches Candle/MLX
6108                        // shape — `Done.text` is the full assembled
6109                        // generation, not an empty sentinel that
6110                        // forces consumers to reassemble.
6111                        let accum = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
6112                        let accum_cb = accum.clone();
6113                        let cb = crate::backend::foundation_models::StreamCallback::new(
6114                            move |delta: &str| {
6115                                if let Ok(mut g) = accum_cb.lock() {
6116                                    g.push_str(delta);
6117                                }
6118                                tx_clone
6119                                    .blocking_send(stream::StreamEvent::TextDelta(
6120                                        delta.to_string(),
6121                                    ))
6122                                    .is_ok()
6123                            },
6124                        );
6125                        let result = crate::backend::foundation_models::stream(
6126                            &prompt,
6127                            instructions.as_deref(),
6128                            max_tokens,
6129                            temperature as f32,
6130                            cb,
6131                        );
6132                        let final_text = accum.lock().map(|g| g.clone()).unwrap_or_default();
6133                        let _ = tx.blocking_send(stream::StreamEvent::Done {
6134                            text: final_text,
6135                            tool_calls: vec![],
6136                        });
6137                        result
6138                    });
6139                    return Ok((resolved_model_id, rx));
6140                }
6141            }
6142
6143            // MLX streaming — macOS-aarch64 only.
6144            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6145            {
6146                // On Apple Silicon macOS, all local models must go
6147                // through MLX.
6148                if !schema.is_mlx() {
6149                    return Err(InferenceError::InferenceFailed(format!(
6150                        "model '{}' has no MLX equivalent; Candle backend disabled on Apple Silicon",
6151                        schema.id
6152                    )));
6153                }
6154                let (backend, _retention) = self
6155                    .ensure_mlx_backend(
6156                        &schema,
6157                        local_reservation
6158                            .as_mut()
6159                            .expect("local stream has admission reservation"),
6160                    )
6161                    .await?;
6162                let model_id = schema.id.clone();
6163                let cache = Arc::clone(&self.mlx_backends);
6164                // Serialize on the shared Metal device (see `mlx_device_lock`)
6165                // before the blocking eval, mirroring the media paths: acquire the
6166                // owned guard here and move it INTO the blocking closure so it is
6167                // held for the whole stream and survives RPC-deadline abandonment.
6168                // Without it a concurrent embed/other-model MLX eval (e.g. memory
6169                // consolidation) can wedge the one Metal device.
6170                let device_guard = Self::mlx_device_lock().lock_owned().await;
6171                // MLX ops are blocking (GPU-bound) and `MutexGuard<MlxBackend>`
6172                // isn't `Send`, so run the whole generation on a blocking
6173                // worker. `tx.blocking_send` bridges tokens back to the
6174                // async stream consumer without holding the guard across
6175                // an `.await`.
6176                tokio::task::spawn_blocking(move || {
6177                    let _device_guard = device_guard;
6178                    let _ = Self::stream_local_mlx(backend, cache, model_id, req, tx);
6179                });
6180                let rx = match local_reservation {
6181                    Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
6182                    None => rx,
6183                };
6184                Ok((resolved_model_id, rx))
6185            }
6186
6187            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6188            {
6189                self.ensure_backend(
6190                    &schema,
6191                    local_reservation
6192                        .as_mut()
6193                        .expect("local stream has admission reservation"),
6194                )
6195                .await?;
6196                let backend = self.backend.clone();
6197                let model_id = schema.id.clone();
6198                tokio::spawn(async move {
6199                    let _ = Self::stream_local_candle(backend, model_id, req, tx).await;
6200                });
6201                let rx = match local_reservation {
6202                    Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
6203                    None => rx,
6204                };
6205                Ok((resolved_model_id, rx))
6206            }
6207        }
6208    }
6209
6210    /// Stream a generation and record an outcome when it finishes.
6211    ///
6212    /// Wraps [`generate_stream_raw`](crate::InferenceEngine::generate_stream_raw) with a forwarding "tap" task: it
6213    /// accumulates the event stream (via [`stream::StreamAccumulator`]),
6214    /// forwards every event to the caller unchanged, and on completion books a
6215    /// success/failure against the model's profile — the same outcome telemetry
6216    /// the non-streaming `generate_tracked` path records. Without this, every
6217    /// streamed inference (voice/realtime, the daemon's `infer` stream) was
6218    /// invisible to model-health scoring. Cancellation propagates: if the
6219    /// caller drops the returned receiver, the tap stops forwarding, drops the
6220    /// producer receiver, and the backend task observes the closed channel.
6221    ///
6222    /// Returns a [`TrackedStream`] carrying the resolved model + this call's
6223    /// `trace_id` alongside the event receiver. The trace_id is known up front
6224    /// (minted by `record_start` before the first token), so a caller can score
6225    /// the finished turn against the same trace the tap will resolve — the
6226    /// streaming counterpart of the non-streaming path's `InferenceResult`
6227    /// `{trace_id, model_used}`, which the conversation-outcome signal needs.
6228    ///
6229    /// The `events` receiver yields `StreamEvent` variants (`TextDelta`,
6230    /// `ToolCallStart`, `ToolCallDelta`, `Usage`, `StopReason`,
6231    /// `ProviderOutputItem`, `Error`, `Done`); use a
6232    /// [`stream::StreamAccumulator`] to collect them into a final result. Local
6233    /// backends (MLX, Candle) emit true incremental `TextDelta`s per token,
6234    /// enabling token-by-token UI, overlapping TTS, and early cancellation. The
6235    /// channel buffers 64 events so burst tokens don't block generation.
6236    ///
6237    /// ## Example: voice app integration
6238    ///
6239    /// ```rust,ignore
6240    /// let mut handle = engine.generate_tracked_stream(req).await?;
6241    /// let mut text_buf = String::new();
6242    /// while let Some(event) = handle.events.recv().await {
6243    ///     match event {
6244    ///         StreamEvent::TextDelta(delta) => {
6245    ///             text_buf.push_str(&delta);
6246    ///             // Feed text_buf to TTS when a sentence boundary is reached
6247    ///         }
6248    ///         StreamEvent::Done { text, .. } => break,
6249    ///         _ => {}
6250    ///     }
6251    /// }
6252    /// // handle.trace_id / handle.model_used identify the turn for scoring.
6253    /// ```
6254    pub async fn generate_tracked_stream(
6255        &self,
6256        req: GenerateRequest,
6257    ) -> Result<TrackedStream, InferenceError> {
6258        // Pre-call input estimate (used when the provider reports no usage,
6259        // e.g. local backends). Computed before `req` is moved into the raw
6260        // producer.
6261        let (estimated_input, _, _) = self.estimated_tokens(&req, None);
6262        let start = Instant::now();
6263
6264        // Setup/routing errors (unknown model, no runner) propagate unchanged
6265        // and are not booked as model failures — same as the non-streaming
6266        // path, which only records once a candidate actually runs.
6267        let (model_id, mut producer_rx) = self.generate_stream_raw(req).await?;
6268
6269        let trace = {
6270            let mut t = self.outcome_tracker.write().await;
6271            t.record_start(&model_id, InferenceTask::Generate, "stream")
6272        };
6273        // Surface trace_id + model to the caller. Cloned before the tap task
6274        // moves `trace` in to resolve the outcome on completion; `model_id` is
6275        // unused after `record_start`, so it moves straight into the handle.
6276        let trace_for_return = trace.clone();
6277
6278        let (out_tx, out_rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
6279        let tracker = Arc::clone(&self.outcome_tracker);
6280        tokio::spawn(async move {
6281            let mut acc = stream::StreamAccumulator::default();
6282            let mut stream_error: Option<String> = None;
6283            let mut saw_done = false;
6284            let mut receiver_abandoned = false;
6285            while let Some(evt) = producer_rx.recv().await {
6286                if let stream::StreamEvent::Error(message) = &evt {
6287                    stream_error = Some(message.clone());
6288                }
6289                if matches!(evt, stream::StreamEvent::Done { .. }) {
6290                    saw_done = true;
6291                }
6292                acc.push(&evt);
6293                if out_tx.send(evt).await.is_err() {
6294                    receiver_abandoned = true;
6295                    break;
6296                }
6297            }
6298            if stream_error.is_none() && !saw_done && !receiver_abandoned {
6299                let error = "stream ended without positive provider completion".to_string();
6300                stream_error = Some(error.clone());
6301                let _ = out_tx.send(stream::StreamEvent::Error(error)).await;
6302            } else if stream_error.is_none() && receiver_abandoned {
6303                stream_error = Some("stream receiver was abandoned before completion".to_string());
6304            }
6305            let (text, tool_calls, usage, _stop) = acc.finish_with_usage();
6306            let latency_ms = start.elapsed().as_millis() as u64;
6307            let input_tokens = usage
6308                .as_ref()
6309                .map(|u| u.prompt_tokens as usize)
6310                .unwrap_or(estimated_input);
6311            // Output token count: provider usage if present, else word count.
6312            // A tool-only response (no text) is still a success, so floor the
6313            // count at 1 when tool calls were produced — record_complete gates
6314            // its mechanical-success credit on output_tokens > 0.
6315            let mut output_tokens = usage
6316                .as_ref()
6317                .map(|u| u.completion_tokens as usize)
6318                .unwrap_or_else(|| text.split_whitespace().count());
6319            if output_tokens == 0 && !tool_calls.is_empty() {
6320                output_tokens = 1;
6321            }
6322            // Cache split — currently always 0 on the streaming path because the
6323            // accumulator does not yet decode Anthropic `message_start` cache
6324            // deltas; threaded anyway so this path prices correctly the moment
6325            // it does.
6326            let (cache_read, cache_creation) = usage
6327                .as_ref()
6328                .map(|u| {
6329                    (
6330                        u.cache_read_input_tokens as usize,
6331                        u.cache_creation_input_tokens as usize,
6332                    )
6333                })
6334                .unwrap_or((0, 0));
6335            let mut t = tracker.write().await;
6336            if let Some(error) = stream_error {
6337                // Credential/setup rejection cannot reach this resolver:
6338                // `generate_stream_raw` returns its typed error before the
6339                // `record_start` above. This arm sees only errors emitted after
6340                // the stream opened, when the producer had already dispatched.
6341                t.record_failure(&trace, &error);
6342            } else {
6343                t.record_complete_cached(
6344                    &trace,
6345                    latency_ms,
6346                    input_tokens,
6347                    output_tokens,
6348                    cache_read,
6349                    cache_creation,
6350                );
6351            }
6352        });
6353
6354        Ok(TrackedStream {
6355            model_used: model_id,
6356            trace_id: trace_for_return,
6357            events: out_rx,
6358        })
6359    }
6360
6361    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6362    async fn stream_local_candle(
6363        backend_lock: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
6364        model_id: String,
6365        req: GenerateRequest,
6366        tx: tokio::sync::mpsc::Sender<stream::StreamEvent>,
6367    ) -> Result<(), InferenceError> {
6368        let mut write = backend_lock.write().await;
6369        let backend = write.get_mut(&model_id).ok_or_else(|| {
6370            InferenceError::InferenceFailed(format!("backend not initialized for {model_id}"))
6371        })?;
6372        backend.clear_kv_cache();
6373
6374        let formatted = tasks::generate::render_chat_prompt(&req);
6375        let tokens = backend.encode(&formatted)?;
6376        let eos = backend.eos_token_id();
6377        let eos_alt = backend.token_id("<|im_end|>");
6378        let params = &req.params;
6379
6380        if tokens.is_empty() {
6381            let _ = tx
6382                .send(stream::StreamEvent::Done {
6383                    text: String::new(),
6384                    tool_calls: vec![],
6385                })
6386                .await;
6387            return Ok(());
6388        }
6389
6390        let max_ctx = backend.context_length().unwrap_or(32768);
6391        let headroom = params.max_tokens.min(max_ctx / 4);
6392        let max_prompt = max_ctx.saturating_sub(headroom);
6393        let tokens = if tokens.len() > max_prompt {
6394            tokens[tokens.len() - max_prompt..].to_vec()
6395        } else {
6396            tokens
6397        };
6398
6399        let mut generated = Vec::new();
6400        let logits = backend.forward(&tokens, 0)?;
6401        let mut next_token = tasks::generate::sample_token(&logits, params)?;
6402
6403        for _ in 0..params.max_tokens {
6404            if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
6405                break;
6406            }
6407
6408            generated.push(next_token);
6409            let delta = backend.decode(&[next_token])?;
6410            if !delta.is_empty()
6411                && tx
6412                    .send(stream::StreamEvent::TextDelta(delta))
6413                    .await
6414                    .is_err()
6415            {
6416                return Ok(());
6417            }
6418
6419            if !params.stop.is_empty() {
6420                let text_so_far = backend.decode(&generated)?;
6421                if params.stop.iter().any(|s| text_so_far.contains(s)) {
6422                    break;
6423                }
6424            }
6425
6426            let pos = tokens.len() + generated.len() - 1;
6427            let logits = backend.forward(&[next_token], pos)?;
6428            next_token = tasks::generate::sample_token(&logits, params)?;
6429        }
6430
6431        let trimmed = tasks::generate::truncate_at_stop(&backend.decode(&generated)?, &params.stop);
6432        let text = tasks::generate::strip_thinking(&trimmed, params.thinking);
6433        // Real counts, same as the MLX stream and the non-streaming candle
6434        // path — see the note in `stream_local_mlx` (Parslee-ai/car#795).
6435        // `tokens.len()` is post-truncation: what the model actually saw.
6436        let _ = tx
6437            .send(stream::StreamEvent::Usage {
6438                input_tokens: tokens.len() as u64,
6439                output_tokens: generated.len() as u64,
6440                cache_read_input_tokens: 0,
6441                cache_creation_input_tokens: 0,
6442            })
6443            .await;
6444        let _ = tx
6445            .send(stream::StreamEvent::Done {
6446                text,
6447                tool_calls: vec![],
6448            })
6449            .await;
6450        Ok(())
6451    }
6452
6453    /// Blocking streaming generator — runs on a `spawn_blocking` worker
6454    /// so the sync MLX mutex guard isn't held across an async `.await`.
6455    /// Uses `tx.blocking_send` to push tokens back to the caller.
6456    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6457    fn stream_local_mlx(
6458        handle: backend_cache::CachedBackend<backend::MlxBackend>,
6459        cache: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
6460        model_id: String,
6461        req: GenerateRequest,
6462        tx: tokio::sync::mpsc::Sender<stream::StreamEvent>,
6463    ) -> Result<(), InferenceError> {
6464        let mut guard = handle.lock().map_err(|_| {
6465            InferenceError::InferenceFailed(format!("MLX backend mutex poisoned for {model_id}"))
6466        })?;
6467        let backend: &mut backend::MlxBackend = &mut guard;
6468        backend.clear_kv_cache();
6469
6470        let formatted = tasks::generate::render_chat_prompt(&req);
6471        let tokens = backend.encode(&formatted)?;
6472        let eos = backend.eos_token_id();
6473        let eos_alt = backend.token_id("<|im_end|>");
6474        let params = &req.params;
6475
6476        if tokens.is_empty() {
6477            let _ = tx.blocking_send(stream::StreamEvent::Done {
6478                text: String::new(),
6479                tool_calls: vec![],
6480            });
6481            return Ok(());
6482        }
6483
6484        let max_ctx = backend.context_length();
6485        let headroom = params.max_tokens.min(max_ctx / 4);
6486        let max_prompt = max_ctx.saturating_sub(headroom);
6487        let tokens = if tokens.len() > max_prompt {
6488            tokens[tokens.len() - max_prompt..].to_vec()
6489        } else {
6490            tokens
6491        };
6492
6493        let mut generated = Vec::new();
6494
6495        // Same wall-clock ceiling and progress reporting the non-streaming loop
6496        // got in car#851. A streamed decode at least emits tokens as it goes,
6497        // so a client can tell it is alive — but it is reached by the daemon's
6498        // `infer_stream` RPC and by voice, and before this it had no bound at
6499        // all beyond `max_tokens`.
6500        let started = std::time::Instant::now();
6501        let timeout = local_decode_timeout();
6502        let heartbeat = std::time::Duration::from_secs(LOCAL_DECODE_HEARTBEAT_SECS);
6503        let mut last_heartbeat = std::time::Duration::ZERO;
6504        tracing::info!(
6505            prompt_tokens = tokens.len(),
6506            max_tokens = params.max_tokens,
6507            timeout_secs = timeout.map(|t| t.as_secs()),
6508            "local stream prefill starting"
6509        );
6510
6511        // Wrap MLX forward calls to catch panics. On panic, drop this
6512        // backend from the cache — its KV cache may be in an
6513        // indeterminate state and subsequent callers would inherit it.
6514        // Outstanding handles continue to work until their Arc drops.
6515        let logits = match Self::catch_mlx("stream prefill", || backend.forward(&tokens, 0)) {
6516            Ok(v) => v,
6517            Err(e) => {
6518                cache.invalidate(&model_id);
6519                return Err(e);
6520            }
6521        };
6522        let mut next_token = Self::sample_from_logits(&logits, params)?;
6523
6524        for _ in 0..params.max_tokens {
6525            if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
6526                break;
6527            }
6528
6529            generated.push(next_token);
6530            let delta = backend.decode(&[next_token])?;
6531            if !delta.is_empty()
6532                && tx
6533                    .blocking_send(stream::StreamEvent::TextDelta(delta))
6534                    .is_err()
6535            {
6536                return Ok(());
6537            }
6538
6539            if !params.stop.is_empty() {
6540                let text_so_far = backend.decode(&generated)?;
6541                if params.stop.iter().any(|s| text_so_far.contains(s)) {
6542                    break;
6543                }
6544            }
6545
6546            // Deadline AFTER the push/send and the stop check, matching
6547            // `drive_generation_with_timeout` exactly. Checking it earlier drops
6548            // the token already sampled — the streamed and non-streamed partials
6549            // would then differ by one token for the same cut-off.
6550            let elapsed = started.elapsed();
6551            if deadline_exceeded(elapsed, timeout) {
6552                tracing::warn!(
6553                    elapsed_secs = elapsed.as_secs_f64(),
6554                    timeout_secs = timeout.map(|t| t.as_secs()),
6555                    completion_tokens = generated.len(),
6556                    max_tokens = params.max_tokens,
6557                    "local stream hit its wall-clock ceiling and was cut short; \
6558                     returning what was streamed so far. Raise or disable it with \
6559                     CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables)."
6560                );
6561                // Without this the consumer cannot tell a cut-off stream from a
6562                // finished one: `StreamAccumulator::was_truncated()` keys off
6563                // the stop reason, and the local streaming path never emitted
6564                // one. The daemon's `infer_stream` RPC and voice both read it.
6565                let _ = tx.blocking_send(stream::StreamEvent::StopReason(
6566                    LOCAL_DECODE_TIMEOUT_STOP_REASON.to_string(),
6567                ));
6568                break;
6569            }
6570            if heartbeat_due(elapsed, last_heartbeat, heartbeat) {
6571                last_heartbeat = elapsed;
6572                tracing::info!(
6573                    completion_tokens = generated.len(),
6574                    max_tokens = params.max_tokens,
6575                    elapsed_secs = elapsed.as_secs_f64(),
6576                    tokens_per_sec =
6577                        generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON),
6578                    "local stream in progress"
6579                );
6580            }
6581
6582            let pos = tokens.len() + generated.len() - 1;
6583            let logits =
6584                match Self::catch_mlx("stream forward", || backend.forward(&[next_token], pos)) {
6585                    Ok(v) => v,
6586                    Err(e) => {
6587                        cache.invalidate(&model_id);
6588                        return Err(e);
6589                    }
6590                };
6591            next_token = Self::sample_from_logits(&logits, params)?;
6592        }
6593
6594        let trimmed = tasks::generate::truncate_at_stop(&backend.decode(&generated)?, &params.stop);
6595        let text = tasks::generate::strip_thinking(&trimmed, params.thinking);
6596        // Report the counts this loop already knows, the way a remote provider
6597        // reports its own (Parslee-ai/car#795). Without this event the
6598        // accumulator's `saw_usage` stays false and EVERY streamed local
6599        // generation ends with `usage: null`, while the non-streaming MLX path
6600        // right next to it returns real numbers — so the same model reported
6601        // tokens or didn't purely on whether the caller streamed.
6602        // `tokens.len()` is post-truncation: what the model actually saw.
6603        let _ = tx.blocking_send(stream::StreamEvent::Usage {
6604            input_tokens: tokens.len() as u64,
6605            output_tokens: generated.len() as u64,
6606            // In-process inference has no remote prompt cache.
6607            cache_read_input_tokens: 0,
6608            cache_creation_input_tokens: 0,
6609        });
6610        let _ = tx.blocking_send(stream::StreamEvent::Done {
6611            text,
6612            tool_calls: vec![],
6613        });
6614        Ok(())
6615    }
6616
6617    /// Route a prompt using the adaptive router without executing inference.
6618    pub async fn route_context_snapshot(
6619        &self,
6620        prompt: &str,
6621        workload: RoutingWorkload,
6622        has_tools: bool,
6623        has_vision: bool,
6624    ) -> AdaptiveRoutingDecision {
6625        let routing_registry = self.catalog_registry_snapshot();
6626        let tracker = self.outcome_tracker.read().await;
6627        self.adaptive_router.route_context_aware(
6628            prompt,
6629            0,
6630            &routing_registry,
6631            &tracker,
6632            has_tools,
6633            has_vision,
6634            workload,
6635        )
6636    }
6637
6638    /// Generate text from a prompt (legacy API, no outcome tracking).
6639    /// When `req.model` is None, uses intelligent routing based on prompt complexity.
6640    pub async fn generate(&self, req: GenerateRequest) -> Result<String, InferenceError> {
6641        Ok(self.generate_tracked(req).await?.text)
6642    }
6643
6644    /// Encode `text` via the named model's tokenizer. Returns raw token IDs
6645    /// without any chat-template wrapping or BOS-prepending — pair with
6646    /// [`Self::detokenize`] for the round-trip property
6647    /// `detokenize(model, tokenize(model, s)) == s` for any UTF-8 `s`.
6648    ///
6649    /// Only local models have a tokenizer the runtime can call directly
6650    /// (Candle/GGUF on Linux/Windows, MLX on Apple Silicon). For remote
6651    /// models the call returns
6652    /// [`InferenceError::UnsupportedMode`] — provider tokenizer endpoints
6653    /// vary too widely to be portable here, and bundling tiktoken-style
6654    /// tables would lock the registry to a fixed set of providers.
6655    pub async fn tokenize(&self, model: &str, text: &str) -> Result<Vec<u32>, InferenceError> {
6656        self.assert_local_for_tokenize(model)?;
6657        let admission_schema = self
6658            .unified_registry
6659            .get(model)
6660            .or_else(|| self.unified_registry.find_by_name(model))
6661            .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?
6662            .clone();
6663        // LOCAL_ADMISSION_BOUNDARY:tokenizer-encode
6664        let mut reservation =
6665            self.reserve_local_request(&admission_schema, text.len().div_ceil(4))?;
6666
6667        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6668        {
6669            let schema = admission_schema;
6670            let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6671            let guard = handle.lock().map_err(|_| {
6672                InferenceError::InferenceFailed(format!(
6673                    "MLX backend mutex poisoned for {}",
6674                    schema.id
6675                ))
6676            })?;
6677            let tokens = guard.tokenize_raw(text)?;
6678            Ok(tokens)
6679        }
6680
6681        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6682        {
6683            self.ensure_backend(&admission_schema, &mut reservation)
6684                .await?;
6685            let read = self.backend.read().await;
6686            let backend = read.get(&admission_schema.id).ok_or_else(|| {
6687                InferenceError::InferenceFailed(
6688                    "candle backend missing after ensure_backend".to_string(),
6689                )
6690            })?;
6691            let tokens = backend.tokenize_raw(text)?;
6692            Ok(tokens)
6693        }
6694    }
6695
6696    /// Inverse of [`Self::tokenize`]: decode token IDs back to text.
6697    pub async fn detokenize(&self, model: &str, tokens: &[u32]) -> Result<String, InferenceError> {
6698        self.assert_local_for_tokenize(model)?;
6699        let admission_schema = self
6700            .unified_registry
6701            .get(model)
6702            .or_else(|| self.unified_registry.find_by_name(model))
6703            .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?
6704            .clone();
6705        // LOCAL_ADMISSION_BOUNDARY:tokenizer-decode
6706        let mut reservation = self.reserve_local_request(&admission_schema, tokens.len())?;
6707
6708        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6709        {
6710            let schema = admission_schema;
6711            let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6712            let guard = handle.lock().map_err(|_| {
6713                InferenceError::InferenceFailed(format!(
6714                    "MLX backend mutex poisoned for {}",
6715                    schema.id
6716                ))
6717            })?;
6718            let text = guard.detokenize_raw(tokens)?;
6719            Ok(text)
6720        }
6721
6722        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6723        {
6724            self.ensure_backend(&admission_schema, &mut reservation)
6725                .await?;
6726            let read = self.backend.read().await;
6727            let backend = read.get(&admission_schema.id).ok_or_else(|| {
6728                InferenceError::InferenceFailed(
6729                    "candle backend missing after ensure_backend".to_string(),
6730                )
6731            })?;
6732            let text = backend.detokenize_raw(tokens)?;
6733            Ok(text)
6734        }
6735    }
6736
6737    /// Common pre-flight for [`Self::tokenize`] / [`Self::detokenize`]: bail
6738    /// early on remote models with the same `UnsupportedMode` taxonomy used
6739    /// elsewhere on the engine surface.
6740    fn assert_local_for_tokenize(&self, model: &str) -> Result<(), InferenceError> {
6741        if let Some(schema) = self
6742            .unified_registry
6743            .get(model)
6744            .or_else(|| self.unified_registry.find_by_name(model))
6745        {
6746            if !schema.is_local() {
6747                return Err(InferenceError::UnsupportedMode {
6748                    mode: "tokenize/detokenize",
6749                    backend: "remote",
6750                    reason: "remote provider tokenizer is not exposed by the runtime; \
6751                         use a local model (Qwen3 GGUF / MLX) for tokenizer-correctness checks",
6752                });
6753            }
6754        }
6755        // Unknown model name: let the load step surface ModelNotFound below.
6756        Ok(())
6757    }
6758
6759    /// Wrap an MLX FFI call with catch_unwind to catch Rust panics at the boundary.
6760    /// NOTE: This catches Rust panics only, NOT C++ exceptions from Metal/MLX.
6761    /// True C++ exceptions will still abort the process — that requires an upstream
6762    /// fix in mlx-rs to catch C++ exceptions before they cross the FFI boundary.
6763    /// On panic, callers MUST remove the backend from the map — it is poisoned.
6764    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6765    fn catch_mlx<F, T>(context: &str, f: F) -> Result<T, InferenceError>
6766    where
6767        F: FnOnce() -> Result<T, InferenceError>,
6768    {
6769        std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|e| {
6770            InferenceError::InferenceFailed(format!("MLX panicked during {context}: {e:?}"))
6771        })?
6772    }
6773
6774    /// Architecture-neutral decode loop over any [`TextDecoder`] (macOS MLX
6775    /// backends — Qwen3, Gemma 4, …). Owns prompt
6776    /// encoding, context-window truncation, prefill, the sampling/stop loop,
6777    /// TTFT timing, and the FFI-boundary panic-catch — everything that used to
6778    /// be inlined per-backend in `generate_mlx`. The eos convention is the
6779    /// backend's (`eos_ids`), so this loop knows nothing about `<|im_end|>` or
6780    /// any other family's turn-enders. Cache invalidation on a caught panic is
6781    /// the caller's job (it owns the lock guard) — signaled via
6782    /// [`DriveError::BackendCorrupted`].
6783    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6784    fn drive_generation(
6785        backend: &mut dyn backend::local::TextDecoder,
6786        prompt: &str,
6787        params: &GenerateParams,
6788    ) -> Result<backend::local::LocalGeneration, backend::local::DriveError> {
6789        Self::drive_generation_with_timeout(backend, prompt, params, local_decode_timeout())
6790    }
6791
6792    /// `drive_generation`(Self::drive_generation) with the wall-clock ceiling
6793    /// passed in rather than read from the environment, so the deadline is
6794    /// testable without mutating process-global state from a test thread.
6795    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6796    fn drive_generation_with_timeout(
6797        backend: &mut dyn backend::local::TextDecoder,
6798        prompt: &str,
6799        params: &GenerateParams,
6800        timeout: Option<std::time::Duration>,
6801    ) -> Result<backend::local::LocalGeneration, backend::local::DriveError> {
6802        use backend::local::{DriveError, LocalGeneration};
6803
6804        let start = std::time::Instant::now();
6805
6806        let tokens = backend.encode(prompt).map_err(DriveError::Recoverable)?;
6807        let eos_ids = backend.eos_ids();
6808
6809        if tokens.is_empty() {
6810            backend.clear_kv_cache();
6811            return Ok(LocalGeneration {
6812                text: String::new(),
6813                ttft_ms: None,
6814                stop_reason: None,
6815                prompt_tokens: 0,
6816                completion_tokens: 0,
6817            });
6818        }
6819
6820        // Truncate to context length, reserving headroom for the response.
6821        let max_ctx = backend.context_length();
6822        let headroom = params.max_tokens.min(max_ctx / 4);
6823        let max_prompt = max_ctx.saturating_sub(headroom);
6824        let tokens = if tokens.len() > max_prompt {
6825            tokens[tokens.len() - max_prompt..].to_vec()
6826        } else {
6827            tokens
6828        };
6829
6830        let mut generated = Vec::new();
6831
6832        // Announce BEFORE prefill, not after. Prefill is a single MLX forward
6833        // over the whole prompt and nothing can interrupt it — on a large model
6834        // with an agent's tools block it is tens of seconds on its own. Logging
6835        // only after it would leave exactly the window car#851 was reported for
6836        // still silent, and would let a prefill stall masquerade as a slow
6837        // decode.
6838        tracing::info!(
6839            prompt_tokens = tokens.len(),
6840            max_tokens = params.max_tokens,
6841            timeout_secs = timeout.map(|t| t.as_secs()),
6842            "local prefill starting"
6843        );
6844
6845        // Reuse any cached matching prefix (prompt caching); prefill only the
6846        // new suffix. `begin_prompt` returns the position to start from; the
6847        // default (Qwen) clears and returns 0 (full re-prefill).
6848        let offset = backend.begin_prompt(&tokens);
6849        let logits = Self::catch_mlx("prefill", || backend.forward(&tokens[offset..], offset))
6850            .map_err(DriveError::BackendCorrupted)?;
6851        let mut next_token =
6852            Self::sample_from_logits(&logits, params).map_err(DriveError::Recoverable)?;
6853        let ttft_ms = Some(start.elapsed().as_millis() as u64);
6854
6855        // The loop below is silent for its whole duration, which is what made a
6856        // 24-minute decode indistinguishable from a wedged process in car#851.
6857        // This line alone would have exposed the real budget: the tracing span
6858        // records `max_tokens` at entry, BEFORE the routing layer widens it.
6859        tracing::info!(
6860            prompt_tokens = tokens.len(),
6861            max_tokens = params.max_tokens,
6862            prefill_ms = ttft_ms,
6863            timeout_secs = timeout.map(|t| t.as_secs()),
6864            "local decode starting"
6865        );
6866
6867        // Track why the loop ended: a natural EOS / stop-sequence finish, the
6868        // max_tokens budget (truncation), or the wall-clock ceiling.
6869        let mut natural_stop = false;
6870        let mut timed_out = false;
6871        let heartbeat = std::time::Duration::from_secs(LOCAL_DECODE_HEARTBEAT_SECS);
6872        let mut last_heartbeat = std::time::Duration::ZERO;
6873        for _ in 0..params.max_tokens {
6874            if eos_ids.contains(&next_token) {
6875                natural_stop = true;
6876                break;
6877            }
6878
6879            generated.push(next_token);
6880
6881            if !params.stop.is_empty() {
6882                let text_so_far = backend
6883                    .decode(&generated)
6884                    .map_err(DriveError::Recoverable)?;
6885                if params.stop.iter().any(|s| text_so_far.contains(s)) {
6886                    natural_stop = true;
6887                    break;
6888                }
6889            }
6890
6891            let elapsed = start.elapsed();
6892            if deadline_exceeded(elapsed, timeout) {
6893                timed_out = true;
6894                break;
6895            }
6896            if heartbeat_due(elapsed, last_heartbeat, heartbeat) {
6897                last_heartbeat = elapsed;
6898                tracing::info!(
6899                    completion_tokens = generated.len(),
6900                    max_tokens = params.max_tokens,
6901                    elapsed_secs = elapsed.as_secs_f64(),
6902                    tokens_per_sec =
6903                        generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON),
6904                    "local decode in progress"
6905                );
6906            }
6907
6908            let pos = tokens.len() + generated.len() - 1;
6909            let logits = Self::catch_mlx("forward", || backend.forward(&[next_token], pos))
6910                .map_err(DriveError::BackendCorrupted)?;
6911            next_token =
6912                Self::sample_from_logits(&logits, params).map_err(DriveError::Recoverable)?;
6913        }
6914
6915        let elapsed = start.elapsed();
6916        let tokens_per_sec = generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON);
6917        if timed_out {
6918            tracing::warn!(
6919                elapsed_secs = elapsed.as_secs_f64(),
6920                timeout_secs = timeout.map(|t| t.as_secs()),
6921                completion_tokens = generated.len(),
6922                max_tokens = params.max_tokens,
6923                tokens_per_sec,
6924                "local decode hit its wall-clock ceiling and was cut short; \
6925                 returning the partial response. Raise or disable it with \
6926                 CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables)."
6927            );
6928        } else {
6929            tracing::info!(
6930                elapsed_secs = elapsed.as_secs_f64(),
6931                completion_tokens = generated.len(),
6932                max_tokens = params.max_tokens,
6933                tokens_per_sec,
6934                natural_stop,
6935                "local decode finished"
6936            );
6937        }
6938
6939        let decoded = backend
6940            .decode(&generated)
6941            .map_err(DriveError::Recoverable)?;
6942        let text = tasks::generate::truncate_at_stop(&decoded, &params.stop);
6943        let stop_reason = Some(
6944            if timed_out {
6945                LOCAL_DECODE_TIMEOUT_STOP_REASON
6946            } else if natural_stop {
6947                "stop"
6948            } else {
6949                "length"
6950            }
6951            .to_string(),
6952        );
6953        Ok(LocalGeneration {
6954            text: tasks::generate::strip_thinking(&text, params.thinking),
6955            ttft_ms,
6956            stop_reason,
6957            prompt_tokens: tokens.len(),
6958            completion_tokens: generated.len(),
6959        })
6960    }
6961
6962    /// Generate text using the MLX backend.
6963    /// Mirrors the Candle generate loop but uses MlxBackend::forward which returns Vec<f32>.
6964    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6965    /// Returns `(text, time_to_first_token_ms, stop_reason)`. `stop_reason`
6966    /// is `Some("stop")` when generation ended at EOS or a stop sequence,
6967    /// `Some("length")` when it hit the `max_tokens` cap — the OpenAI spelling
6968    /// so [`InferenceResult::was_truncated`] works for local models too
6969    /// (previously local always reported `None`, so a max_tokens cutoff was
6970    /// indistinguishable from a clean finish and the truncation-retry path
6971    /// never fired locally) — and [`LOCAL_DECODE_TIMEOUT_STOP_REASON`]
6972    /// (`"local_decode_timeout"`) when the wall-clock decode ceiling cut it
6973    /// short (car#851).
6974    async fn generate_mlx(
6975        &self,
6976        req: GenerateRequest,
6977        model_id: &str,
6978        reservation: &mut resource_policy::LocalLoadReservation,
6979    ) -> Result<(String, Option<TokenUsage>, Option<u64>, Option<String>), InferenceError> {
6980        let schema = self
6981            .unified_registry
6982            .get(model_id)
6983            .cloned()
6984            .ok_or_else(|| {
6985                InferenceError::InferenceFailed(format!(
6986                    "generate_mlx: unknown schema id {model_id}"
6987                ))
6988            })?;
6989        let (handle, _retention) = self.ensure_mlx_backend(&schema, reservation).await?;
6990        // Serialize on the shared Metal device (see `mlx_device_lock`) before the
6991        // per-model lock + synchronous decode loop. The per-model mutex alone does
6992        // NOT prevent a device-level race with a concurrent embed/other-model MLX
6993        // eval — e.g. memory consolidation embedding on a DIFFERENT backend while
6994        // the coder generates — which hangs the one Metal device (the daemon
6995        // "wedge"). Held across the whole generation, released on function return.
6996        let formatted = tasks::generate::render_chat_prompt(&req);
6997        let params = req.params.clone();
6998        let model_id = model_id.to_string();
6999        let cache = self.mlx_backends.clone();
7000        let device_guard = Self::mlx_device_lock().lock_owned().await;
7001        let detached_lease = reservation.detached_lease();
7002
7003        // MLX prefill/decode is synchronous native work. Keep both the device
7004        // guard and the request's exact machine charge inside the blocking
7005        // closure: the Tokio runtime remains available for the raw WebSocket
7006        // cancel/deadline response, and abandoning that waiter cannot advertise
7007        // capacity while native work is still finishing.
7008        run_admitted_blocking(detached_lease, move || {
7009            let _device_guard = device_guard;
7010            let mut guard = handle.lock().map_err(|_| {
7011                InferenceError::InferenceFailed(format!(
7012                    "MLX backend mutex poisoned for {model_id}"
7013                ))
7014            })?;
7015            let backend: &mut backend::MlxBackend = &mut guard;
7016            let ctx_window = backend.context_length() as u64;
7017            match Self::drive_generation(backend, &formatted, &params) {
7018                Ok(gen) => {
7019                    let usage = TokenUsage {
7020                        prompt_tokens: gen.prompt_tokens as u64,
7021                        completion_tokens: gen.completion_tokens as u64,
7022                        total_tokens: (gen.prompt_tokens + gen.completion_tokens) as u64,
7023                        context_window: ctx_window,
7024                        ..Default::default()
7025                    };
7026                    Ok((gen.text, Some(usage), gen.ttft_ms, gen.stop_reason))
7027                }
7028                Err(backend::local::DriveError::Recoverable(error)) => Err(error),
7029                Err(backend::local::DriveError::BackendCorrupted(error)) => {
7030                    drop(guard);
7031                    cache.invalidate(&model_id);
7032                    Err(error)
7033                }
7034            }
7035        })
7036        .await
7037        .map_err(|error| {
7038            InferenceError::InferenceFailed(format!("native MLX task panicked: {error}"))
7039        })?
7040    }
7041
7042    /// Trait-object analogue of [`generate_mlx`](Self::generate_mlx) for
7043    /// NEW-architecture in-process backends (Gemma 4, …). The backend renders
7044    /// its own architecture-specific prompt; the shared decode loop does the
7045    /// rest. Evicts from `local_backends` on a caught panic.
7046    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7047    async fn generate_local(
7048        &self,
7049        req: GenerateRequest,
7050        model_id: &str,
7051        reservation: &mut resource_policy::LocalLoadReservation,
7052    ) -> Result<
7053        (
7054            String,
7055            Vec<tasks::generate::ToolCall>,
7056            Option<TokenUsage>,
7057            Option<u64>,
7058            Option<String>,
7059        ),
7060        InferenceError,
7061    > {
7062        let schema = self
7063            .unified_registry
7064            .get(model_id)
7065            .cloned()
7066            .ok_or_else(|| {
7067                InferenceError::InferenceFailed(format!(
7068                    "generate_local: unknown schema id {model_id}"
7069                ))
7070            })?;
7071        let (handle, _retention) = self.ensure_local_backend(&schema, reservation).await?;
7072        let params = req.params.clone();
7073        let model_id = model_id.to_string();
7074        let cache = self.local_backends.clone();
7075        let device_guard = Self::mlx_device_lock().lock_owned().await;
7076        let detached_lease = reservation.detached_lease();
7077
7078        run_admitted_blocking(detached_lease, move || {
7079            let _device_guard = device_guard;
7080            let mut guard = handle.lock().map_err(|_| {
7081                InferenceError::InferenceFailed(format!(
7082                    "local backend mutex poisoned for {model_id}"
7083                ))
7084            })?;
7085            let formatted = guard.render_prompt(&req)?;
7086            let backend: &mut dyn backend::local::TextDecoder = &mut **guard;
7087            let outcome = Self::drive_generation(backend, &formatted, &params);
7088            match outcome {
7089                Ok(gen) => {
7090                    let (clean, tool_calls) = guard.parse_tool_calls(&gen.text);
7091                    let usage = TokenUsage {
7092                        prompt_tokens: gen.prompt_tokens as u64,
7093                        completion_tokens: gen.completion_tokens as u64,
7094                        total_tokens: (gen.prompt_tokens + gen.completion_tokens) as u64,
7095                        context_window: guard.context_length() as u64,
7096                        ..Default::default()
7097                    };
7098                    Ok((clean, tool_calls, Some(usage), gen.ttft_ms, gen.stop_reason))
7099                }
7100                Err(backend::local::DriveError::Recoverable(error)) => Err(error),
7101                Err(backend::local::DriveError::BackendCorrupted(error)) => {
7102                    drop(guard);
7103                    cache.invalidate(&model_id);
7104                    Err(error)
7105                }
7106            }
7107        })
7108        .await
7109        .map_err(|error| {
7110            InferenceError::InferenceFailed(format!("native local task panicked: {error}"))
7111        })?
7112    }
7113
7114    /// Apply top-k then top-p (nucleus) truncation to a probability vector
7115    /// in place: keep the `top_k` highest-probability entries (k=0 disables),
7116    /// then keep the smallest prefix whose cumulative mass exceeds `top_p`
7117    /// (p>=1.0 disables), zeroing the rest and renormalizing. Pure and
7118    /// platform-independent (not cfg-gated) so it's unit-testable without an
7119    /// MLX backend. Mirrors the Candle sampler's ordering.
7120    #[allow(dead_code)] // used by the MLX sampler + unit tests; dead in car_skip_mlx builds
7121    fn apply_top_k_top_p(probs: &mut [f32], top_k: usize, top_p: f64) {
7122        // Top-k: zero everything outside the k highest probabilities.
7123        if top_k > 0 && top_k < probs.len() {
7124            let mut indexed: Vec<(usize, f32)> = probs.iter().copied().enumerate().collect();
7125            indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7126            let allowed: std::collections::HashSet<usize> =
7127                indexed[..top_k].iter().map(|(i, _)| *i).collect();
7128            for (i, p) in probs.iter_mut().enumerate() {
7129                if !allowed.contains(&i) {
7130                    *p = 0.0;
7131                }
7132            }
7133            let sum: f32 = probs.iter().sum();
7134            if sum > 0.0 {
7135                for p in probs.iter_mut() {
7136                    *p /= sum;
7137                }
7138            }
7139        }
7140
7141        // Top-p (nucleus): keep the smallest high-prob prefix exceeding top_p.
7142        if top_p < 1.0 {
7143            let mut indexed: Vec<(usize, f32)> = probs.iter().copied().enumerate().collect();
7144            indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7145            let mut cumsum = 0.0f32;
7146            let mut cutoff_idx = indexed.len();
7147            for (i, &(_, p)) in indexed.iter().enumerate() {
7148                cumsum += p;
7149                if cumsum > top_p as f32 {
7150                    cutoff_idx = i + 1;
7151                    break;
7152                }
7153            }
7154            let allowed: std::collections::HashSet<usize> =
7155                indexed[..cutoff_idx].iter().map(|(i, _)| *i).collect();
7156            for (i, p) in probs.iter_mut().enumerate() {
7157                if !allowed.contains(&i) {
7158                    *p = 0.0;
7159                }
7160            }
7161            let sum: f32 = probs.iter().sum();
7162            if sum > 0.0 {
7163                for p in probs.iter_mut() {
7164                    *p /= sum;
7165                }
7166            }
7167        }
7168    }
7169
7170    /// Sample a token from a logits Vec<f32> (shared by every local backend).
7171    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7172    fn sample_from_logits(logits: &[f32], params: &GenerateParams) -> Result<u32, InferenceError> {
7173        if params.temperature <= 0.0 {
7174            // Greedy: argmax
7175            let (idx, _) = logits
7176                .iter()
7177                .enumerate()
7178                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
7179                .ok_or_else(|| InferenceError::InferenceFailed("empty logits".into()))?;
7180            return Ok(idx as u32);
7181        }
7182
7183        // Temperature-scaled softmax sampling
7184        let temp = params.temperature as f32;
7185        let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
7186        let mut probs: Vec<f32> = logits
7187            .iter()
7188            .map(|&l| ((l - max_logit) / temp).exp())
7189            .collect();
7190        let sum: f32 = probs.iter().sum();
7191        for p in &mut probs {
7192            *p /= sum;
7193        }
7194
7195        // Top-k then top-p (nucleus) truncation. Pulled into a pure helper so
7196        // it's deterministic and unit-testable (only the final draw below uses
7197        // rng). top_k was previously absent here — a request specifying top_k
7198        // was silently a no-op on Apple Silicon while the Candle sampler
7199        // (tasks::generate::sample_token) honored it.
7200        Self::apply_top_k_top_p(&mut probs, params.top_k, params.top_p);
7201
7202        // Sample from distribution
7203        use rand::Rng;
7204        let mut rng = rand::rng();
7205        let r: f32 = rng.random();
7206        let mut cumsum = 0.0;
7207        for (i, &p) in probs.iter().enumerate() {
7208            cumsum += p;
7209            if cumsum >= r {
7210                return Ok(i as u32);
7211            }
7212        }
7213        Ok((probs.len() - 1) as u32)
7214    }
7215
7216    /// Generate embeddings for text using the dedicated embedding model.
7217    /// On Apple Silicon, uses the native MLX backend; on other platforms, uses Candle.
7218    pub async fn embed(&self, req: EmbedRequest) -> Result<Vec<Vec<f32>>, InferenceError> {
7219        let instruction = req
7220            .instruction
7221            .as_deref()
7222            .unwrap_or("Retrieve relevant memory facts");
7223        let embedding_model = self
7224            .preferred_model_for_capability(ModelCapability::Embed)
7225            .unwrap_or(&self.config.embedding_model);
7226        let admission_schema = self
7227            .unified_registry
7228            .get(embedding_model)
7229            .or_else(|| self.unified_registry.find_by_name(embedding_model))
7230            .ok_or_else(|| InferenceError::ModelNotFound(embedding_model.to_string()))?
7231            .clone();
7232        let estimated_tokens = req.texts.iter().map(|text| text.len().div_ceil(4)).sum();
7233        // LOCAL_ADMISSION_BOUNDARY:embedding-dispatch
7234        let mut reservation = self.reserve_local_request(&admission_schema, estimated_tokens)?;
7235
7236        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7237        {
7238            let model_id = self.ensure_mlx_embedding_backend().await?;
7239            let schema = self
7240                .unified_registry
7241                .get(&model_id)
7242                .cloned()
7243                .ok_or_else(|| {
7244                    InferenceError::InferenceFailed(format!("embed: unknown schema id {model_id}"))
7245                })?;
7246            let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
7247            // Device serialization (see `generate_mlx`): this embed path is the
7248            // memory-consolidation caller that was racing the coder's generate on
7249            // a different backend and wedging the Metal device. Same device lock.
7250            let _device_guard = Self::mlx_device_lock().lock_owned().await;
7251            let mut guard = handle.lock().map_err(|_| {
7252                InferenceError::InferenceFailed(format!(
7253                    "MLX embedding backend mutex poisoned for {model_id}"
7254                ))
7255            })?;
7256            let backend: &mut backend::MlxBackend = &mut guard;
7257
7258            let mut results = Vec::with_capacity(req.texts.len());
7259            for text in &req.texts {
7260                let embedding = if req.is_query {
7261                    backend.embed_query(text, instruction)?
7262                } else {
7263                    backend.embed_one(text)?
7264                };
7265                results.push(embedding);
7266            }
7267            Ok(results)
7268        }
7269
7270        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7271        {
7272            self.ensure_embedding_backend(&mut reservation).await?;
7273            let mut write = self.embedding_backend.write().await;
7274            let backend = write.as_mut().unwrap();
7275
7276            let mut results = Vec::with_capacity(req.texts.len());
7277            for text in &req.texts {
7278                let embedding = if req.is_query {
7279                    backend.embed_query(text, instruction)?
7280                } else {
7281                    backend.embed_one(text)?
7282                };
7283                results.push(embedding);
7284            }
7285            Ok(results)
7286        }
7287    }
7288
7289    /// Rerank candidate documents against a query using a cross-encoder
7290    /// reranker model (Qwen3-Reranker family). Returns documents sorted
7291    /// by descending relevance.
7292    ///
7293    /// ## Scoring
7294    ///
7295    /// Qwen3-Reranker is a Qwen3 base LM fine-tuned so that the first
7296    /// assistant token is `"yes"` or `"no"` given the templated
7297    /// `<Instruct>/<Query>/<Document>` user turn. We run a short
7298    /// greedy decode (≤ 3 tokens, so a leading space, BOS artifact, or
7299    /// the occasional newline don't break us) and score
7300    /// `yes → 1.0`, `no → 0.0`, anything else → `0.5` with a warning.
7301    ///
7302    /// This is a **binary** score — the soft probability
7303    /// `softmax(logit_yes, logit_no)` would give finer ordering but
7304    /// requires per-token logit access on `backend::MlxBackend`,
7305    /// which isn't exposed publicly yet. Tracked as a follow-up;
7306    /// binary scores still produce a correct partial ordering, just
7307    /// with coarser tiebreaks within the {yes} or {no} groups.
7308    ///
7309    /// ## Prompt template
7310    ///
7311    /// We emit the upstream Qwen3-Reranker chat template verbatim:
7312    /// a dedicated system prompt fixing the yes/no answer space,
7313    /// then the user turn with `<Instruct>/<Query>/<Document>`, then
7314    /// the assistant prefix with a closed empty `<think>` block to
7315    /// suppress thinking (reranker is not a reasoner — it's a
7316    /// classifier). Deviating from this template produces sharply
7317    /// degraded yes/no distributions.
7318    pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResult, InferenceError> {
7319        if req.documents.is_empty() {
7320            return Ok(RerankResult {
7321                ranked: Vec::new(),
7322                model_used: None,
7323            });
7324        }
7325
7326        let model_name = match req.model.clone() {
7327            Some(m) => m,
7328            None => self
7329                .preferred_model_for_capability(ModelCapability::Rerank)
7330                .map(str::to_string)
7331                .ok_or_else(|| {
7332                    InferenceError::InferenceFailed(
7333                        "no reranker model available — pull a Qwen3-Reranker model first".into(),
7334                    )
7335                })?,
7336        };
7337
7338        let schema = self
7339            .unified_registry
7340            .find_by_name(&model_name)
7341            .or_else(|| self.unified_registry.get(&model_name))
7342            .cloned()
7343            .ok_or_else(|| {
7344                InferenceError::InferenceFailed(format!(
7345                    "rerank: unknown reranker model {model_name}"
7346                ))
7347            })?;
7348        if !schema.has_capability(ModelCapability::Rerank) {
7349            return Err(InferenceError::InferenceFailed(format!(
7350                "model {} does not declare the Rerank capability",
7351                schema.name
7352            )));
7353        }
7354
7355        let instruction = req.instruction.as_deref().unwrap_or(
7356            "Given a web search query, retrieve relevant passages that answer the query",
7357        );
7358
7359        let mut scored: Vec<RerankedDocument> = Vec::with_capacity(req.documents.len());
7360        for (idx, doc) in req.documents.iter().enumerate() {
7361            let prompt = rerank_prompt(instruction, &req.query, doc);
7362            let gen_req = GenerateRequest {
7363                prompt,
7364                model: Some(schema.id.clone()),
7365                params: tasks::generate::GenerateParams {
7366                    temperature: 0.0,
7367                    // Three tokens is enough to scan past a leading
7368                    // space, BOS, or newline that some tokenizers
7369                    // insert before the real yes/no token.
7370                    max_tokens: 3,
7371                    thinking: tasks::generate::ThinkingMode::Off,
7372                    ..Default::default()
7373                },
7374                context: None,
7375                context_stable_prefix: None,
7376                tools: None,
7377                images: None,
7378                messages: None,
7379                cache_control: false,
7380                response_format: None,
7381                intent: None,
7382                client_ref: None,
7383                expected_row_digest: None,
7384                expected_catalog_revision: None,
7385                caller: None,
7386            };
7387            let out = self.generate(gen_req).await?;
7388            let score = score_from_rerank_output(&out, &schema.name);
7389            scored.push(RerankedDocument {
7390                index: idx,
7391                score,
7392                document: doc.clone(),
7393            });
7394        }
7395
7396        // Sort descending by score; preserve original index as a
7397        // deterministic tiebreaker. top_n must truncate after sorting.
7398        scored.sort_by(|a, b| {
7399            b.score
7400                .partial_cmp(&a.score)
7401                .unwrap_or(std::cmp::Ordering::Equal)
7402                .then_with(|| a.index.cmp(&b.index))
7403        });
7404        if let Some(n) = req.top_n {
7405            scored.truncate(n);
7406        }
7407
7408        Ok(RerankResult {
7409            ranked: scored,
7410            model_used: Some(schema.name),
7411        })
7412    }
7413
7414    /// Dedicated endpoint for structured visual grounding.
7415    ///
7416    /// Runs a VL generate call under the hood and parses Qwen2.5-VL's
7417    /// inline `<|object_ref_*|>...<|box_*|>(x1,y1),(x2,y2)` spans into
7418    /// typed [`BoundingBox`]es. Distinct from the generic
7419    /// [`InferenceEngine::generate`] + `InferenceResult.bounding_boxes`
7420    /// path so callers can express "I want boxes" as a first-class
7421    /// intent — which also lets the router prefer models that declare
7422    /// the `Grounding` capability.
7423    pub async fn ground(&self, req: GroundRequest) -> Result<GroundResult, InferenceError> {
7424        let model_name = match req.model.clone() {
7425            Some(m) => m,
7426            None => self
7427                .preferred_model_for_capability(ModelCapability::Grounding)
7428                .map(str::to_string)
7429                .ok_or_else(|| {
7430                    InferenceError::InferenceFailed(
7431                        "no grounding-capable model available — pull a Qwen2.5-VL model first"
7432                            .into(),
7433                    )
7434                })?,
7435        };
7436
7437        let gen_req = GenerateRequest {
7438            prompt: req.prompt.clone(),
7439            model: Some(model_name),
7440            params: GenerateParams::default(),
7441            context: None,
7442            context_stable_prefix: None,
7443            tools: None,
7444            images: Some(vec![req.image.clone()]),
7445            messages: None,
7446            cache_control: false,
7447            response_format: None,
7448            intent: None,
7449            client_ref: None,
7450            expected_row_digest: None,
7451            expected_catalog_revision: None,
7452            caller: None,
7453        };
7454        let result = self.generate_tracked(gen_req).await?;
7455        Ok(GroundResult {
7456            boxes: result.bounding_boxes,
7457            raw_text: result.text,
7458            model_used: Some(result.model_used),
7459        })
7460    }
7461
7462    /// Classify text against candidate labels.
7463    /// When `req.model` is None, routes to the smallest available model.
7464    pub async fn classify(
7465        &self,
7466        req: ClassifyRequest,
7467    ) -> Result<Vec<ClassifyResult>, InferenceError> {
7468        let model = match req.model.clone().or_else(|| {
7469            self.preferred_model_for_capability(ModelCapability::Classify)
7470                .map(str::to_string)
7471        }) {
7472            Some(m) => m,
7473            None => {
7474                let m = self.router.route_small(&self.registry);
7475                debug!(model = %m, "auto-routed classify request");
7476                m
7477            }
7478        };
7479        let schema = self
7480            .unified_registry
7481            .get(&model)
7482            .or_else(|| self.unified_registry.find_by_name(&model))
7483            .ok_or_else(|| InferenceError::ModelNotFound(model.clone()))?
7484            .clone();
7485
7486        if !schema.is_local() {
7487            return self.classify_via_generate(req, &schema.id).await;
7488        }
7489
7490        // On Apple Silicon, route through the main generate path (which uses MLX)
7491        // instead of the Candle backend directly.
7492        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7493        {
7494            return self.classify_via_generate(req, &model).await;
7495        }
7496
7497        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7498        {
7499            // LOCAL_ADMISSION_BOUNDARY:candle-classify
7500            let mut reservation =
7501                self.reserve_local_request(&schema, req.text.len().div_ceil(4))?;
7502            self.ensure_backend(&schema, &mut reservation).await?;
7503            let mut write = self.backend.write().await;
7504            let backend = write.get_mut(&schema.id).ok_or_else(|| {
7505                InferenceError::InferenceFailed(format!(
7506                    "candle backend missing after ensure_backend for {}",
7507                    schema.id
7508                ))
7509            })?;
7510            let result = tasks::classify::classify(backend, req).await?;
7511            Ok(result)
7512        }
7513    }
7514
7515    /// Classify by routing through the main generate path (remote providers on
7516    /// every platform; native MLX on Apple Silicon).
7517    async fn classify_via_generate(
7518        &self,
7519        req: ClassifyRequest,
7520        model: &str,
7521    ) -> Result<Vec<ClassifyResult>, InferenceError> {
7522        let labels_str = req
7523            .labels
7524            .iter()
7525            .enumerate()
7526            .map(|(i, l)| format!("{}. {}", i + 1, l))
7527            .collect::<Vec<_>>()
7528            .join("\n");
7529
7530        let prompt = format!(
7531            "Classify the following text into one of these categories:\n\
7532             {labels_str}\n\n\
7533             Text: {}\n\n\
7534             Respond with ONLY the category name, nothing else.",
7535            req.text
7536        );
7537
7538        let gen_req = GenerateRequest {
7539            prompt,
7540            model: Some(model.to_string()),
7541            params: tasks::generate::GenerateParams {
7542                temperature: 0.0,
7543                max_tokens: 32,
7544                // Classification is latency-sensitive and single-label;
7545                // force the fast no-thinking path even on Qwen3.
7546                thinking: tasks::generate::ThinkingMode::Off,
7547                ..Default::default()
7548            },
7549            context: None,
7550            context_stable_prefix: None,
7551            tools: None,
7552            images: None,
7553            messages: None,
7554            cache_control: false,
7555            response_format: None,
7556            intent: None,
7557            client_ref: None,
7558            expected_row_digest: None,
7559            expected_catalog_revision: None,
7560            caller: None,
7561        };
7562
7563        let response = self.generate(gen_req).await?;
7564        let response_lower = response.trim().to_lowercase();
7565
7566        let mut results: Vec<ClassifyResult> = req
7567            .labels
7568            .iter()
7569            .map(|label| {
7570                let label_lower = label.to_lowercase();
7571                let score = if response_lower == label_lower {
7572                    1.0
7573                } else if response_lower.contains(&label_lower) {
7574                    0.8
7575                } else {
7576                    let label_words: Vec<&str> = label_lower.split_whitespace().collect();
7577                    let matches = label_words
7578                        .iter()
7579                        .filter(|w| response_lower.contains(**w))
7580                        .count();
7581                    if label_words.is_empty() {
7582                        0.0
7583                    } else {
7584                        0.5 * (matches as f64 / label_words.len() as f64)
7585                    }
7586                };
7587                ClassifyResult {
7588                    label: label.clone(),
7589                    score,
7590                }
7591            })
7592            .collect();
7593
7594        results.sort_by(|a, b| {
7595            b.score
7596                .partial_cmp(&a.score)
7597                .unwrap_or(std::cmp::Ordering::Equal)
7598        });
7599
7600        let total: f64 = results.iter().map(|r| r.score).sum();
7601        if total > 0.0 {
7602            for r in &mut results {
7603                r.score /= total;
7604            }
7605        }
7606
7607        Ok(results)
7608    }
7609
7610    /// Transcribe an audio file using the best available STT model.
7611    pub async fn transcribe(
7612        &self,
7613        req: TranscribeRequest,
7614    ) -> Result<TranscribeResult, InferenceError> {
7615        let candidates =
7616            self.speech_candidates(ModelCapability::SpeechToText, req.model.as_deref())?;
7617        let mut last_error = None;
7618        let mut local_resource_block = None;
7619
7620        for schema in candidates {
7621            // LOCAL_ADMISSION_BOUNDARY:speech-stt-dispatch
7622            let mut reservation = match self.admit_speech_candidate(&schema, req.model.is_some()) {
7623                SpeechCandidateAdmission::Proceed(reservation) => reservation,
7624                SpeechCandidateAdmission::SkipBlocked(error) => {
7625                    local_resource_block = Some(error.to_string());
7626                    last_error = Some(error);
7627                    continue;
7628                }
7629                SpeechCandidateAdmission::FailBlocked(error) => return Err(error),
7630            };
7631            let result = match &schema.source {
7632                ModelSource::Mlx { .. } => {
7633                    self.transcribe_local_mlx(&schema, &req, reservation.as_mut())
7634                        .await
7635                }
7636                ModelSource::WhisperCpp { model } => {
7637                    self.transcribe_whisper(&schema, model, &req, reservation.as_mut())
7638                        .await
7639                }
7640                ModelSource::Proprietary { provider, .. } if provider == "elevenlabs" => {
7641                    self.transcribe_elevenlabs(&schema, &req).await
7642                }
7643                _ => Err(InferenceError::InferenceFailed(format!(
7644                    "speech-to-text not implemented for model source: {}",
7645                    schema.id
7646                ))),
7647            };
7648
7649            match result {
7650                Ok(mut result) => {
7651                    if matches!(schema.source, ModelSource::Proprietary { .. }) {
7652                        if let Some(reason) = local_resource_block.take() {
7653                            result.routing_explanation = Some(format!(
7654                                "Local speech recognition was blocked ({reason}); CAR routed to a remote provider, which may affect privacy and cost."
7655                            ));
7656                        }
7657                    }
7658                    return Ok(result);
7659                }
7660                Err(err) if req.model.is_some() => return Err(err),
7661                Err(err) => last_error = Some(err),
7662            }
7663        }
7664
7665        Err(last_error.unwrap_or_else(|| {
7666            InferenceError::InferenceFailed("no speech-to-text models available".into())
7667        }))
7668    }
7669
7670    /// Synthesize speech using the best available TTS model.
7671    pub async fn synthesize(
7672        &self,
7673        req: SynthesizeRequest,
7674    ) -> Result<SynthesizeResult, InferenceError> {
7675        let candidates =
7676            self.speech_candidates(ModelCapability::TextToSpeech, req.model.as_deref())?;
7677        let mut last_error = None;
7678        let mut local_resource_block = None;
7679
7680        for schema in candidates {
7681            // LOCAL_ADMISSION_BOUNDARY:speech-tts-dispatch
7682            let mut reservation = match self.admit_speech_candidate(&schema, req.model.is_some()) {
7683                SpeechCandidateAdmission::Proceed(reservation) => reservation,
7684                SpeechCandidateAdmission::SkipBlocked(error) => {
7685                    local_resource_block = Some(error.to_string());
7686                    last_error = Some(error);
7687                    continue;
7688                }
7689                SpeechCandidateAdmission::FailBlocked(error) => return Err(error),
7690            };
7691            let result = match &schema.source {
7692                ModelSource::Mlx { .. } => {
7693                    self.synthesize_local_mlx(&schema, &req, reservation.as_mut())
7694                        .await
7695                }
7696                ModelSource::WindowsSpeech {} => {
7697                    self.synthesize_windows_speech(&schema, &req).await
7698                }
7699                ModelSource::Proprietary { provider, .. } if provider == "elevenlabs" => {
7700                    self.synthesize_elevenlabs(&schema, &req).await
7701                }
7702                _ => Err(InferenceError::InferenceFailed(format!(
7703                    "text-to-speech not implemented for model source: {}",
7704                    schema.id
7705                ))),
7706            };
7707
7708            match result {
7709                Ok(mut result) => {
7710                    if matches!(schema.source, ModelSource::Proprietary { .. }) {
7711                        if let Some(reason) = local_resource_block.take() {
7712                            result.routing_explanation = Some(format!(
7713                                "Local speech synthesis was blocked ({reason}); CAR routed to a remote provider, which may affect privacy and cost."
7714                            ));
7715                        }
7716                    }
7717                    return Ok(result);
7718                }
7719                Err(err) if req.model.is_some() => return Err(err),
7720                Err(err) => last_error = Some(err),
7721            }
7722        }
7723
7724        Err(last_error.unwrap_or_else(|| {
7725            InferenceError::InferenceFailed("no text-to-speech models available".into())
7726        }))
7727    }
7728
7729    /// Synthesize speech with the Windows OS synthesizer (`WindowsSpeech`,
7730    /// WinRT `Windows.Media.SpeechSynthesis`) — the catalog-side counterpart of
7731    /// car-voice's live `TtsProvider::WindowsSpeech`. Writes a WAV file at the
7732    /// requested (or a temp) path. Windows-only; the availability gate keeps
7733    /// this off the candidate list on every other target.
7734    async fn synthesize_windows_speech(
7735        &self,
7736        schema: &ModelSchema,
7737        req: &SynthesizeRequest,
7738    ) -> Result<SynthesizeResult, InferenceError> {
7739        #[cfg(target_os = "windows")]
7740        {
7741            let text = req.text.clone();
7742            let voice = req.voice.clone().unwrap_or_default();
7743            let rate = req.speed.unwrap_or(1.0) as f64;
7744            let bytes =
7745                tokio::task::spawn_blocking(move || winrt_synthesize_wav(&text, &voice, rate))
7746                    .await
7747                    .map_err(|e| {
7748                        InferenceError::InferenceFailed(format!("winrt tts join: {e}"))
7749                    })??;
7750            let dest = requested_or_temp_output(req.output_path.as_deref(), "wav")?;
7751            ensure_parent_dir(&dest)?;
7752            std::fs::write(&dest, &bytes)?;
7753            Ok(SynthesizeResult {
7754                audio_path: dest.to_string_lossy().to_string(),
7755                media_type: "audio/wav".to_string(),
7756                model_used: Some(schema.name.clone()),
7757                voice_used: req.voice.clone(),
7758                routing_explanation: None,
7759            })
7760        }
7761        #[cfg(not(target_os = "windows"))]
7762        {
7763            let _ = (schema, req);
7764            Err(InferenceError::InferenceFailed(
7765                "Windows OS TTS is only available on Windows".into(),
7766            ))
7767        }
7768    }
7769
7770    /// Generate an image using the best available local MLX image model.
7771    pub async fn generate_image(
7772        &self,
7773        req: GenerateImageRequest,
7774    ) -> Result<GenerateImageResult, InferenceError> {
7775        // Dispatch on what the native backend can actually load, not on an env
7776        // toggle. `mlx_flux` implements the Flux.1-lite architecture and reached
7777        // prompt-faithful parity with mflux (parity harness in
7778        // tools/parity/ref_flux.py + diff_flux_blocks.py), so it serves that
7779        // checkpoint. Every other architecture — FLUX.2, Krea 2, Qwen-Image,
7780        // Z-Image, Fibo — has no Rust implementation and goes to mflux.
7781        //
7782        // This is the same rule the text path uses (`backend::local::
7783        // has_native_backend`): the model decides the backend. The previous
7784        // `CAR_IMAGE_BACKEND` env toggle existed for A/B comparison *during* the
7785        // parity migration; that migration completed, and a toggle outliving its
7786        // purpose is exactly the "works for me but not for you" failure mode
7787        // CLAUDE.md's no-flags rule exists to prevent. It also defaulted to
7788        // native unconditionally, so asking for FLUX.2 silently got a backend
7789        // that cannot load it.
7790        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7791        {
7792            use crate::backend::external_flux;
7793            let use_external = !external_flux::native_backend_serves(req.model.as_deref());
7794            if use_external {
7795                tracing::info!(
7796                    model = req.model.as_deref().unwrap_or("<default>"),
7797                    "no native Rust implementation for this image architecture; routing to mflux"
7798                );
7799                let schema = self
7800                    .media_generation_candidates(
7801                        ModelCapability::ImageGeneration,
7802                        req.model.as_deref(),
7803                    )?
7804                    .into_iter()
7805                    .next()
7806                    .ok_or_else(|| {
7807                        InferenceError::InferenceFailed(
7808                            "no image generation models available".into(),
7809                        )
7810                    })?;
7811                // LOCAL_ADMISSION_BOUNDARY:image-external-subprocess
7812                let mut reservation = self.reserve_local_request(&schema, 0)?;
7813                let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7814                reservation
7815                    .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
7816                    .map_err(InferenceError::from)?;
7817                let mut req = req;
7818                req.model = self.resolve_external_hf_repo(
7819                    req.model.as_deref(),
7820                    ModelCapability::ImageGeneration,
7821                );
7822                return external_flux::generate_image(&req);
7823            }
7824            tracing::info!("using native Rust MLX Flux backend");
7825        }
7826
7827        let candidates = self
7828            .media_generation_candidates(ModelCapability::ImageGeneration, req.model.as_deref())?;
7829        let mut last_error = None;
7830
7831        for schema in candidates {
7832            // LOCAL_ADMISSION_BOUNDARY:image-dispatch
7833            #[cfg_attr(
7834                not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
7835                allow(unused_mut, unused_variables)
7836            )]
7837            let mut reservation = self.reserve_local_request(&schema, 0)?;
7838            let result = match &schema.source {
7839                #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7840                ModelSource::Mlx { .. } => {
7841                    self.generate_image_native_mlx(&schema, &req, &mut reservation)
7842                        .await
7843                }
7844                _ => Err(InferenceError::InferenceFailed(format!(
7845                    "image generation not implemented for model source: {}",
7846                    schema.id
7847                ))),
7848            };
7849
7850            match result {
7851                Ok(result) => return Ok(result),
7852                Err(err) if req.model.is_some() => return Err(err),
7853                Err(err) => last_error = Some(err),
7854            }
7855        }
7856
7857        Err(last_error.unwrap_or_else(|| {
7858            InferenceError::InferenceFailed("no image generation models available".into())
7859        }))
7860    }
7861
7862    /// Generate one or more variants in a single call.
7863    ///
7864    /// Returns `req.variant_count` results (defaulting to 1). The
7865    /// current MLX Flux backend doesn't support native batching, so
7866    /// this loops over `generate_image` with the seed advanced per
7867    /// variant for visual diversity. A future hosted backend
7868    /// (gpt-image-2, Replicate) can short-circuit this with one
7869    /// network call producing N coherent images.
7870    ///
7871    /// Per-variant errors abort the batch — there's no partial-
7872    /// success semantics today. Callers needing more lenient
7873    /// behaviour should call `generate_image` directly in their own
7874    /// loop.
7875    ///
7876    /// Closes #110.
7877    pub async fn generate_image_batch(
7878        &self,
7879        req: GenerateImageRequest,
7880    ) -> Result<Vec<GenerateImageResult>, InferenceError> {
7881        let count = req.variant_count.unwrap_or(1).max(1);
7882        if count == 1 {
7883            return self.generate_image(req).await.map(|r| vec![r]);
7884        }
7885        let base_seed = req.seed.unwrap_or(0);
7886        let mut results = Vec::with_capacity(count as usize);
7887        for i in 0..count {
7888            // Vary the seed per variant so backends that key prompt
7889            // → output deterministically actually produce different
7890            // images. Callers wanting reproducible single-seed
7891            // variants override `seed` per call themselves.
7892            let mut variant_req = req.clone();
7893            variant_req.seed = Some(base_seed.wrapping_add(i as u64));
7894            // Suppress variant_count on the inner call to avoid
7895            // recursion — generate_image ignores the field today,
7896            // but this also documents intent.
7897            variant_req.variant_count = Some(1);
7898            results.push(self.generate_image(variant_req).await?);
7899        }
7900        Ok(results)
7901    }
7902
7903    /// One process-wide lock over the single Metal device, shared by EVERY
7904    /// native MLX generate path (flux image, ltx video, and any future MLX
7905    /// media backend such as kokoro TTS). Two concurrent MLX evals race the
7906    /// command encoder and segfault the whole process inside
7907    /// `mlx::core::metal::Device::end_encoding`. The per-model `handle.lock()`
7908    /// only serializes calls that share ONE cached backend — the LRU cache can
7909    /// hand a second call a freshly-loaded instance on a different mutex, and
7910    /// image-vs-video are different mutexes entirely — so a device-wide lock is
7911    /// the only thing that actually serializes the GPU. Held *inside* the
7912    /// `spawn_blocking` closure so it survives RPC-deadline abandonment of the
7913    /// outer future.
7914    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7915    fn mlx_device_lock() -> Arc<tokio::sync::Mutex<()>> {
7916        static MLX_DEVICE_LOCK: std::sync::OnceLock<Arc<tokio::sync::Mutex<()>>> =
7917            std::sync::OnceLock::new();
7918        MLX_DEVICE_LOCK
7919            .get_or_init(|| Arc::new(tokio::sync::Mutex::new(())))
7920            .clone()
7921    }
7922
7923    /// Native MLX Flux image generation (no Python shelling).
7924    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7925    async fn generate_image_native_mlx(
7926        &self,
7927        schema: &ModelSchema,
7928        req: &GenerateImageRequest,
7929        reservation: &mut resource_policy::LocalLoadReservation,
7930    ) -> Result<GenerateImageResult, InferenceError> {
7931        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7932        let size = backend_cache::estimate_model_size(&model_dir);
7933        // LOCAL_ADMISSION_BOUNDARY:image-dispatch
7934        let (handle, _retention) = Self::load_backend_healing(
7935            &schema.id,
7936            model_dir,
7937            &self.flux_cache,
7938            size,
7939            reservation,
7940            backend::mlx_flux::FluxBackend::load,
7941            || self.unified_registry.redownload_local(&schema.id),
7942        )
7943        .await?;
7944        // Serialize on the shared Metal device (see `mlx_device_lock`) before
7945        // running the synchronous, GPU-bound eval on a blocking worker. The
7946        // per-model mutex alone does NOT prevent a device-level race with a
7947        // concurrent video/other-model eval.
7948        let req = req.clone();
7949        let device_guard = Self::mlx_device_lock().lock_owned().await;
7950        tokio::task::spawn_blocking(move || -> Result<GenerateImageResult, InferenceError> {
7951            // Held for the full native eval; released at closure end.
7952            let _device_guard = device_guard;
7953            let mut guard = handle.lock().map_err(|_| {
7954                InferenceError::InferenceFailed("flux backend mutex poisoned".into())
7955            })?;
7956            guard.generate(&req)
7957        })
7958        .await
7959        .map_err(|e| InferenceError::InferenceFailed(format!("flux task join: {e}")))?
7960    }
7961
7962    /// Native MLX LTX-2.3 video generation (no Python shelling).
7963    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7964    async fn generate_video_native_mlx(
7965        &self,
7966        schema: &ModelSchema,
7967        req: &GenerateVideoRequest,
7968        reservation: &mut resource_policy::LocalLoadReservation,
7969    ) -> Result<GenerateVideoResult, InferenceError> {
7970        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7971        let size = backend_cache::estimate_model_size(&model_dir);
7972        // LOCAL_ADMISSION_BOUNDARY:video-dispatch
7973        let (handle, _retention) = Self::load_backend_healing(
7974            &schema.id,
7975            model_dir,
7976            &self.ltx_cache,
7977            size,
7978            reservation,
7979            backend::mlx_ltx::LtxBackend::load,
7980            || self.unified_registry.redownload_local(&schema.id),
7981        )
7982        .await?;
7983        let req = req.clone();
7984
7985        // Process-wide single-permit lock on the in-process MLX *video*
7986        // device eval. Two concurrent MLX evals on the one Metal device
7987        // race the command encoder and segfault the whole daemon inside
7988        // `mlx::core::metal::Device::end_encoding` (null encoder; observed
7989        // 2026-06-24, two `LtxBackend::generate` threads live at once).
7990        //
7991        // Neither existing guard prevents this:
7992        //   * the admission semaphore (`car-server-core::admission`) is
7993        //     RAM-sized (≈1 permit / 8 GB, up to 8) — it bounds LLM
7994        //     activations, not GPU eval, so it freely admits N>1 video
7995        //     generations on a roomy host;
7996        //   * the per-instance `handle.lock()` below only serializes
7997        //     calls that share ONE cached backend — the LRU cache can
7998        //     hand a second call a freshly-loaded instance (esp. after a
7999        //     deadline-orphaned first call), so the two lock different
8000        //     mutexes and run the device concurrently.
8001        //
8002        // This lock is independent of both: it gates the device itself.
8003        // The guard is MOVED into the blocking closure rather than held
8004        // by this async future, so it survives an RPC-deadline abandon:
8005        // a `spawn_blocking` job can't be cancelled, so the orphaned
8006        // native eval keeps the lock until it actually finishes and the
8007        // next video eval waits instead of overlapping (and crashing).
8008        // NOTE: other in-process MLX backends (flux image, kokoro TTS)
8009        // share the same Metal device and should adopt this lock too for
8010        // full cross-modality coverage — tracked as a follow-up; this
8011        // change fixes the observed video-vs-video crash.
8012        // Shared with flux image + any future MLX media path (see
8013        // `mlx_device_lock`) — a video eval must not run concurrently with an
8014        // image eval on the one Metal device.
8015        let device_guard = Self::mlx_device_lock().lock_owned().await;
8016
8017        tokio::task::spawn_blocking(move || -> Result<GenerateVideoResult, InferenceError> {
8018            // Held for the full native eval; released at closure end,
8019            // which is what lets the next waiting video eval proceed.
8020            let _device_guard = device_guard;
8021            let mut guard = handle.lock().map_err(|_| {
8022                InferenceError::InferenceFailed("ltx backend mutex poisoned".into())
8023            })?;
8024            guard.generate(&req)
8025        })
8026        .await
8027        .map_err(|e| InferenceError::InferenceFailed(format!("ltx task join: {e}")))?
8028    }
8029
8030    /// Generate a video using the best available local MLX video model.
8031    pub async fn generate_video(
8032        &self,
8033        req: GenerateVideoRequest,
8034    ) -> Result<GenerateVideoResult, InferenceError> {
8035        // Validate the request shape up front so callers get a clean
8036        // error rather than a backend failure deep in the stack.
8037        if let Err(msg) = req.validate() {
8038            return Err(InferenceError::InferenceFailed(format!(
8039                "invalid GenerateVideoRequest: {}",
8040                msg
8041            )));
8042        }
8043        // Consumed only by the MLX LTX video path below; unused on non-MLX builds.
8044        #[allow(unused_variables)]
8045        let requires_audio_conditioning = req.requires_audio_passthrough_opt_in();
8046        // Backend selection: LTX can use CAR's native Rust MLX backend,
8047        // or the legacy external `ltx-2-mlx` bridge when requested.
8048        let candidates = self
8049            .media_generation_candidates(ModelCapability::VideoGeneration, req.model.as_deref())?;
8050        let mut last_error = None;
8051
8052        for schema in candidates {
8053            // LOCAL_ADMISSION_BOUNDARY:video-dispatch
8054            let mut reservation = self.reserve_local_request(&schema, 0)?;
8055            let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
8056            reservation
8057                .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
8058                .map_err(InferenceError::from)?;
8059            let result = match &schema.source {
8060                #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
8061                ModelSource::Mlx { hf_repo, .. } => {
8062                    // The native Rust LTX port reaches quality parity with
8063                    // upstream `ltx-2-mlx` (#40 / #45), so it serves ordinary
8064                    // generation. The one thing it genuinely cannot do is
8065                    // audio-reference conditioning, which only the external
8066                    // `ltx-2-mlx a2v` CLI implements — that is a capability
8067                    // difference, not a preference, so it is what decides here.
8068                    //
8069                    // The former `CAR_VIDEO_BACKEND` toggle existed to A/B the
8070                    // port against the CLI while it reached parity. Parity
8071                    // landed and the toggle outlived it, leaving a runtime knob
8072                    // that silently changed which engine ran — the "works for
8073                    // me but not for you" failure mode CLAUDE.md's no-flags rule
8074                    // exists to prevent.
8075                    let use_external_ltx = requires_audio_conditioning;
8076                    if requires_audio_conditioning && !crate::backend::external_ltx::is_available()
8077                    {
8078                        return Err(InferenceError::InferenceFailed(
8079                            "audio-reference video conditioning requires the external `ltx-2-mlx a2v` CLI on PATH"
8080                                .to_string(),
8081                        ));
8082                    }
8083                    if use_external_ltx {
8084                        let mut req = req.clone();
8085                        req.model = Some(hf_repo.clone());
8086                        crate::backend::external_ltx::generate_video(&req)
8087                    } else {
8088                        self.generate_video_native_mlx(&schema, &req, &mut reservation)
8089                            .await
8090                    }
8091                }
8092                _ => Err(InferenceError::InferenceFailed(format!(
8093                    "video generation not implemented for model source: {}",
8094                    schema.id
8095                ))),
8096            };
8097
8098            match result {
8099                Ok(result) => return Ok(result),
8100                Err(err) if req.model.is_some() => return Err(err),
8101                Err(err) => last_error = Some(err),
8102            }
8103        }
8104
8105        Err(last_error.unwrap_or_else(|| {
8106            InferenceError::InferenceFailed("no video generation models available".into())
8107        }))
8108    }
8109
8110    /// List all known models and their status (new registry).
8111    /// Why `model` cannot honor a `response_format`, or `None` when it can
8112    /// (or is unknown here). Asks the SAME protocol handler the remote path
8113    /// consults (`ProtocolHandler::supports_response_format`) so a CLI can
8114    /// warn before a run rather than discover the `UnsupportedMode` on the
8115    /// repair turn. The Parslee gateway rejects every format separately in
8116    /// `execute_request`, so it is named here too.
8117    pub fn response_format_rejection_reason(
8118        &self,
8119        model: &str,
8120        rf: &crate::tasks::generate::ResponseFormat,
8121    ) -> Option<String> {
8122        let registry = self.unified_registry.clone();
8123        let schema = registry
8124            .list()
8125            .into_iter()
8126            .find(|s| s.id == model || s.name == model)?;
8127        match &schema.source {
8128            ModelSource::Proprietary { .. } => {
8129                Some("the Parslee gateway does not accept response_format".to_string())
8130            }
8131            ModelSource::RemoteApi { protocol, .. } => {
8132                let handler = crate::protocol::handler_for(*protocol);
8133                (!handler.supports_response_format(rf)).then(|| {
8134                    format!(
8135                        "the {} protocol rejects response_format",
8136                        handler.protocol_name()
8137                    )
8138                })
8139            }
8140            _ => None,
8141        }
8142    }
8143
8144    /// The unified catalog, annotated for the machine this engine runs on
8145    /// under its active local-model resource policy — the same policy
8146    /// `models.preflight` admits against, kept in step with the persisted
8147    /// one by `apply_local_resource_policy`.
8148    pub fn list_models_unified(&self) -> Vec<ModelInfo> {
8149        let hardware = HardwareInfo::detect();
8150        let policy = self.active_local_resource_policy();
8151        self.list_models_unified_for(&hardware, &policy.policy)
8152    }
8153
8154    /// The fit annotation for one schema on this machine under the active
8155    /// policy — `list_models_unified`'s verdict for a row built elsewhere
8156    /// (`models.search`), so every catalog surface publishes the same one.
8157    pub fn model_fit(&self, schema: &ModelSchema) -> recommend::ModelFit {
8158        recommend::model_fit(
8159            schema,
8160            &HardwareInfo::detect(),
8161            Some(&self.active_local_resource_policy().policy),
8162        )
8163    }
8164
8165    /// [`Self::list_models_unified`] against explicit hardware and policy.
8166    /// Every row is returned in registry order with every existing field
8167    /// unchanged; the fit annotation is computed per call and never stored.
8168    pub fn list_models_unified_for(
8169        &self,
8170        hardware: &HardwareInfo,
8171        policy: &resource_policy::ResourcePolicy,
8172    ) -> Vec<ModelInfo> {
8173        let mut registry = self.unified_registry.clone();
8174        registry.prune_missing_on_disk_models();
8175        registry.refresh_availability();
8176        registry
8177            .list()
8178            .iter()
8179            .map(|schema| {
8180                let mut info = ModelInfo::from(*schema).with_fit(recommend::model_fit(
8181                    schema,
8182                    hardware,
8183                    Some(policy),
8184                ));
8185                if schema.downloads_weights() {
8186                    let enabled = self
8187                        .model_management
8188                        .car_enabled(&schema.id)
8189                        .unwrap_or(false);
8190                    if !enabled {
8191                        // `weights_ready` remains a physical diagnostic, but
8192                        // legacy clients route on `available`. Never advertise
8193                        // a tombstoned local model as routeable.
8194                        info.available = false;
8195                    }
8196                    if enabled
8197                        && self
8198                            .model_management
8199                            .load_receipt(&schema.id)
8200                            .is_ok_and(|receipt| receipt.is_none())
8201                    {
8202                        // Legacy CAR installs sometimes left only a managed
8203                        // symlink. That is safe to adopt automatically because
8204                        // deletion remains limited to the link and the exact
8205                        // shared target is captured in the receipt. Plain
8206                        // directories and shared-cache-only artifacts stay
8207                        // usable but unowned until explicit adoption.
8208                        if let Ok(_mutation) = self.model_management.begin_mutation(&schema.id) {
8209                            if let Some(path) = registry.existing_local_artifact(&schema.id) {
8210                                if std::fs::symlink_metadata(&path)
8211                                    .is_ok_and(|metadata| metadata.file_type().is_symlink())
8212                                {
8213                                    let generation = self
8214                                        .resource_policy_generation
8215                                        .load(std::sync::atomic::Ordering::Acquire);
8216                                    let _ = self.model_management.record_managed_artifact(
8217                                        &schema.id,
8218                                        model_source_identity(schema),
8219                                        None,
8220                                        generation,
8221                                        true,
8222                                        path,
8223                                    );
8224                                }
8225                            }
8226                        }
8227                    }
8228                    let receipt = self.model_management.load_receipt(&schema.id);
8229                    let directory_cleanup_unsupported =
8230                        !model_management::directory_removal_supported()
8231                            && receipt.as_ref().is_ok_and(|receipt| {
8232                                receipt.as_ref().is_some_and(|receipt| {
8233                                    receipt.artifact_kind
8234                                        == model_management::ManagedArtifactKind::Directory
8235                                })
8236                            });
8237                    info.car_enabled = enabled;
8238                    info.can_remove = enabled
8239                        && self
8240                            .model_management
8241                            .can_remove(&schema.id)
8242                            .unwrap_or(false);
8243                    info.in_use = self.local_admission.active_request_count(&schema.id) > 0
8244                        || self.local_admission.is_resident(&schema.id)
8245                        || self.local_admission.teardown_pending(&schema.id)
8246                        || self
8247                            .model_management
8248                            .model_in_use(&schema.id)
8249                            .unwrap_or(true);
8250                    info.management_evidence = if !enabled {
8251                        Some("disabled_tombstone".into())
8252                    } else if directory_cleanup_unsupported {
8253                        Some("install_receipt_directory_cleanup_unsupported".into())
8254                    } else if receipt.is_ok_and(|receipt| receipt.is_some()) {
8255                        Some("install_receipt".into())
8256                    } else if info.weights_ready {
8257                        Some("shared_or_hand_installed".into())
8258                    } else {
8259                        None
8260                    };
8261                }
8262                info
8263            })
8264            .collect()
8265    }
8266
8267    pub fn model_management_store(&self) -> &model_management::ModelManagementStore {
8268        &self.model_management
8269    }
8270
8271    fn ensure_model_enabled(&self, model_id: &str) -> Result<(), InferenceError> {
8272        if self.model_management.car_enabled(model_id)? {
8273            return Ok(());
8274        }
8275        Err(InferenceError::InferenceFailed(format!(
8276            "local model {model_id} was removed from CAR; reinstall it before use"
8277        )))
8278    }
8279
8280    /// Report installed models that have curated newer replacements.
8281    pub fn available_model_upgrades(&self) -> Vec<ModelUpgrade> {
8282        self.unified_registry.available_upgrades()
8283    }
8284
8285    /// The proactive-upgrade decision for right now: which curated upgrades to
8286    /// auto-apply (under `Auto` policy) and the single nudge to surface, with
8287    /// throttling and dismissals applied. The daemon calls this on its periodic
8288    /// check and broadcasts `decision.nudge` over WebSocket. Returns the loaded
8289    /// `NudgeState` too so the caller can stamp `last_nudge_secs` after sending.
8290    pub async fn check_upgrade_nudge(
8291        &self,
8292        inference_active: bool,
8293    ) -> (crate::nudge::NudgeDecision, crate::nudge::NudgeState) {
8294        let findings = self.detect_upgrades().await;
8295        let prefs = self.update_prefs();
8296        let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
8297        let now = std::time::SystemTime::now()
8298            .duration_since(std::time::UNIX_EPOCH)
8299            .map(|d| d.as_secs())
8300            .unwrap_or(0);
8301        let decision = crate::nudge::decide_nudge(
8302            &findings,
8303            &prefs,
8304            &state,
8305            now,
8306            crate::nudge::DEFAULT_THROTTLE_SECS,
8307            inference_active,
8308        );
8309        (decision, state)
8310    }
8311
8312    /// Record that the user dismissed a nudge (by its `dismiss_key`), so it is
8313    /// never surfaced again. Persists to `~/.car/nudge-state.json`.
8314    pub fn dismiss_upgrade_nudge(&self, dismiss_key: &str) -> Result<(), InferenceError> {
8315        let path = crate::nudge::NudgeState::default_path();
8316        let mut state = crate::nudge::NudgeState::load_from(&path);
8317        state.dismiss(dismiss_key);
8318        state
8319            .save_to(&path)
8320            .map_err(InferenceError::InferenceFailed)
8321    }
8322
8323    /// Run the proactive concierge decision: for the default watched lanes,
8324    /// suggest a model to acquire for any lane the user has nothing installed
8325    /// for. Returns the suggestions plus the loaded [`NudgeState`] so the caller
8326    /// can stamp `last_concierge_secs` after surfacing (mirrors the
8327    /// stamp-after-deliver pattern of [`Self::check_upgrade_nudge`]). The
8328    /// concierge throttles independently of the upgrade nudge.
8329    pub async fn check_concierge(
8330        &self,
8331        inference_active: bool,
8332    ) -> (
8333        Vec<crate::concierge::ConciergeSuggestion>,
8334        crate::nudge::NudgeState,
8335    ) {
8336        let prefs = self.update_prefs();
8337        let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
8338        let hw = crate::hardware::HardwareInfo::detect();
8339        let schemas = self.list_schemas();
8340        let refs: Vec<&ModelSchema> = schemas.iter().collect();
8341        let now = std::time::SystemTime::now()
8342            .duration_since(std::time::UNIX_EPOCH)
8343            .map(|d| d.as_secs())
8344            .unwrap_or(0);
8345        let suggestions = crate::concierge::decide_concierge(
8346            &refs,
8347            &hw,
8348            crate::concierge::DEFAULT_WATCHED_USE_CASES,
8349            crate::intent::QualityTier::Balanced,
8350            &prefs,
8351            &state,
8352            now,
8353            crate::concierge::DEFAULT_CONCIERGE_THROTTLE_SECS,
8354            inference_active,
8355        );
8356        (suggestions, state)
8357    }
8358
8359    /// Record that the user dismissed a concierge suggestion (by its
8360    /// `dismiss_key`), so it is never surfaced again. Shares the same
8361    /// `~/.car/nudge-state.json` `dismissed` list as the upgrade nudge — the key
8362    /// namespaces are disjoint (`concierge:…` vs `from=>to`).
8363    pub fn dismiss_concierge_suggestion(&self, dismiss_key: &str) -> Result<(), InferenceError> {
8364        self.dismiss_upgrade_nudge(dismiss_key)
8365    }
8366
8367    /// Record a *labeled* concierge dismissal (Phase B4/C1) so the Act
8368    /// gate can treat the reason as signal (permanent reasons suppress;
8369    /// `NotNow` cools down). Persists to `~/.car/nudge-state.json`.
8370    pub fn dismiss_concierge_labeled(
8371        &self,
8372        dismiss_key: &str,
8373        reason: crate::concierge::DismissReason,
8374    ) -> Result<(), String> {
8375        let path = crate::nudge::NudgeState::default_path();
8376        let mut state = crate::nudge::NudgeState::load_from(&path);
8377        let now = std::time::SystemTime::now()
8378            .duration_since(std::time::UNIX_EPOCH)
8379            .map(|d| d.as_secs())
8380            .unwrap_or(0);
8381        state.dismiss_labeled(dismiss_key, reason, now);
8382        state.save_to(&path).map_err(|e| e.to_string())
8383    }
8384
8385    /// Net-positive verification (Phase F3): for each lane whose latest
8386    /// action was a `SetDefault` (a switch not yet rolled back), compare
8387    /// the new model's *observed* post-switch success in that lane against
8388    /// the prior model's baseline; if it's measurably worse with enough
8389    /// samples, **auto-revert** to the prior. Never self-graded — the
8390    /// signal is the outcome ledger's verifier/outcome receipts. The
8391    /// daemon calls this on its periodic tick. Returns the reverted lanes.
8392    pub async fn check_canaries(&self) -> Vec<crate::intent::UseCase> {
8393        use crate::action_ledger::ConciergeActionKind;
8394        use crate::concierge::{
8395            canary_verdict, CanaryVerdict, CANARY_MIN_SAMPLES, CANARY_REGRESSION_MARGIN,
8396        };
8397        use std::collections::BTreeMap;
8398
8399        let actions = self.concierge_actions(0);
8400        let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
8401        let entries = crate::outcome::read_ledger(&ledger_path, 0);
8402
8403        // Latest action per global (project=None) lane — append order, last wins.
8404        let mut latest: BTreeMap<
8405            crate::intent::UseCase,
8406            &crate::action_ledger::ConciergeActionEntry,
8407        > = BTreeMap::new();
8408        for a in &actions {
8409            if a.project.is_some() {
8410                continue; // only global lanes canaried for now
8411            }
8412            if let Some(uc) = a.use_case {
8413                latest.insert(uc, a);
8414            }
8415        }
8416
8417        // Decide first (under the tracker read lock), then execute reverts
8418        // after releasing it — rollback acquires the action lock and must
8419        // not nest under the tracker lock.
8420        // (lane, anchor seq) — seq uniquely identifies the standing switch.
8421        let mut reverts: Vec<(crate::intent::UseCase, u64)> = Vec::new();
8422        let tracker = self.outcome_tracker.read().await;
8423        for (uc, a) in latest {
8424            // Only a standing switch (not already rolled back) with a prior
8425            // to fall back to is a canary candidate.
8426            if a.kind != ConciergeActionKind::SetDefault {
8427                continue;
8428            }
8429            let Some(prior) = a.prior_model_id.as_deref() else {
8430                continue; // no baseline → nothing to compare/revert to
8431            };
8432
8433            // New model's post-switch resolved success in this lane.
8434            let (mut succ, mut total) = (0u64, 0u64);
8435            for e in &entries {
8436                if e.timestamp < a.timestamp
8437                    || e.model_id != a.model_id
8438                    || crate::usage_profile::use_case_for_task(e.task) != uc
8439                {
8440                    continue;
8441                }
8442                match e.success {
8443                    Some(true) => {
8444                        succ += 1;
8445                        total += 1;
8446                    }
8447                    Some(false) => total += 1,
8448                    None => {}
8449                }
8450            }
8451            let new_rate = if total == 0 {
8452                None
8453            } else {
8454                Some(succ as f64 / total as f64)
8455            };
8456
8457            // Baseline must be LANE-SCOPED to compare like-for-like: sum the
8458            // prior model's per-task stats across tasks that map to THIS
8459            // lane (not its global lifetime success rate, which mixes other
8460            // lanes and would revert good switches / keep bad ones).
8461            let (mut base_succ, mut base_total) = (0u64, 0u64);
8462            if let Some(profile) = tracker.profile(prior) {
8463                for t in [
8464                    crate::outcome::InferenceTask::Generate,
8465                    crate::outcome::InferenceTask::Embed,
8466                    crate::outcome::InferenceTask::Classify,
8467                    crate::outcome::InferenceTask::Code,
8468                    crate::outcome::InferenceTask::Reasoning,
8469                ] {
8470                    if crate::usage_profile::use_case_for_task(t) != uc {
8471                        continue;
8472                    }
8473                    if let Some(ts) = profile.task_stats(t) {
8474                        base_succ += ts.successes;
8475                        base_total += ts.successes + ts.failures;
8476                    }
8477                }
8478            }
8479            // No real lane baseline for the prior → never auto-revert (don't
8480            // revert against a made-up neutral prior).
8481            if base_total == 0 {
8482                continue;
8483            }
8484            let baseline = base_succ as f64 / base_total as f64;
8485
8486            if canary_verdict(
8487                new_rate,
8488                total,
8489                baseline,
8490                CANARY_MIN_SAMPLES,
8491                CANARY_REGRESSION_MARGIN,
8492            ) == CanaryVerdict::Revert
8493            {
8494                // Conditional revert under the action lock: only undo if
8495                // this exact switch is still the standing one (the user may
8496                // have applied a newer one since we read). Atomic vs. apply.
8497                reverts.push((uc, a.seq));
8498            }
8499        }
8500        drop(tracker);
8501
8502        // Execute the reverts: each is conditional on its anchor still being
8503        // the standing switch (rollback_lane_inner re-checks under the lock).
8504        let mut reverted = Vec::new();
8505        for (uc, seq) in reverts {
8506            if self.rollback_lane_inner(uc, None, Some(seq)).await.is_ok() {
8507                tracing::info!(lane = ?uc, "concierge canary: auto-reverted a worse model switch");
8508                reverted.push(uc);
8509            }
8510        }
8511        reverted
8512    }
8513
8514    /// Conversational concierge (Phase F1/F2): answer a free-form
8515    /// question about the user's models/portfolio, grounded in the
8516    /// observed-usage evidence + the deterministic `recommend()` candidate
8517    /// menu. The LLM *explains* — it runs on a local model, is told to
8518    /// answer ONLY from the supplied evidence, and must not invent a model
8519    /// or assert fit (the grounding oracle already decided fit). This is
8520    /// the ModelConcierge "agent": a thin, constrained `generate` call
8521    /// over assembled receipts, not a freelancing chat.
8522    pub async fn concierge_ask(&self, question: &str) -> Result<String, String> {
8523        use std::fmt::Write as _;
8524        let status = self.concierge_status(false).await;
8525        let hw = crate::hardware::HardwareInfo::detect();
8526        let schemas = self.list_schemas();
8527        let refs: Vec<&ModelSchema> = schemas.iter().collect();
8528
8529        let mut evidence = String::new();
8530        evidence.push_str("OBSERVED USAGE (last 30 days):\n");
8531        if status.lanes.is_empty() {
8532            evidence.push_str("  (no usage recorded yet)\n");
8533        }
8534        for lane in &status.lanes {
8535            let rate = lane
8536                .success_rate()
8537                .map(|r| format!("{:.0}% success", r * 100.0))
8538                .unwrap_or_else(|| "no resolved signal".into());
8539            let _ = writeln!(
8540                evidence,
8541                "  {:?}: {} calls, {}{}",
8542                lane.use_case,
8543                lane.calls,
8544                rate,
8545                if lane.failing_models.is_empty() {
8546                    String::new()
8547                } else {
8548                    format!(
8549                        ", failing on {}",
8550                        lane.failing_models
8551                            .iter()
8552                            .cloned()
8553                            .collect::<Vec<_>>()
8554                            .join(", ")
8555                    )
8556                }
8557            );
8558        }
8559        evidence.push_str("\nMODEL HEALTH:\n");
8560        for m in &status.models {
8561            let success = match m.success_rate {
8562                Some(r) => format!("{:.0}% success", r * 100.0),
8563                None => "no resolved signal".to_string(),
8564            };
8565            let _ = writeln!(
8566                evidence,
8567                "  {}: {} calls, {}, {:.0}ms avg{}",
8568                m.model_id,
8569                m.calls,
8570                success,
8571                m.avg_latency_ms,
8572                if m.excluded { " (excluded)" } else { "" }
8573            );
8574        }
8575        // Grounded candidate menu — the ONLY models the answer may
8576        // reference (with their real fit on this machine). Cover the
8577        // default-watched lanes PLUS every lane the user actually uses, so
8578        // a question about vision/transcription/search has grounded
8579        // candidates instead of forcing the model to improvise.
8580        let mut menu_lanes: Vec<crate::intent::UseCase> =
8581            crate::concierge::DEFAULT_WATCHED_USE_CASES.to_vec();
8582        for lane in &status.lanes {
8583            if !menu_lanes.contains(&lane.use_case) {
8584                menu_lanes.push(lane.use_case);
8585            }
8586        }
8587        evidence.push_str("\nGROUNDED CANDIDATES (fit verified for this machine):\n");
8588        for uc in menu_lanes {
8589            let set = crate::recommend::recommend(
8590                &refs,
8591                &hw,
8592                uc,
8593                crate::intent::QualityTier::Balanced,
8594                crate::intent::Privacy::OnDevice,
8595            );
8596            for p in set.picks.iter().take(3) {
8597                let _ = writeln!(
8598                    evidence,
8599                    "  [{:?}] {} — {}{}",
8600                    uc,
8601                    p.display_name,
8602                    if p.already_installed {
8603                        "installed"
8604                    } else {
8605                        "available"
8606                    },
8607                    if p.fit == crate::recommend::FitStatus::Fits {
8608                        ", fits"
8609                    } else {
8610                        ", does NOT fit"
8611                    }
8612                );
8613            }
8614        }
8615        if let Some(s) = &status.decision.suggestion {
8616            let _ = writeln!(evidence, "\nCURRENT SUGGESTION: {}", s.message);
8617        }
8618
8619        let prompt = format!(
8620            "You are CAR's model concierge. Answer the user's question ONLY from the \
8621             EVIDENCE provided as context — the user's observed model usage, model \
8622             health, and the grounded candidate menu (the only models you may \
8623             mention). NEVER invent a model name and NEVER claim a model fits or is \
8624             better than the evidence states. If the evidence doesn't answer the \
8625             question, say so plainly. Be concise and concrete.\n\nQUESTION: {question}"
8626        );
8627        let evidence_lc = evidence.to_lowercase();
8628        let req = crate::tasks::generate::GenerateRequest {
8629            prompt,
8630            context: Some(evidence),
8631            intent: Some(crate::intent::IntentHint {
8632                task: Some(crate::intent::TaskHint::Chat),
8633                prefer_local: true,
8634                ..Default::default()
8635            }),
8636            ..Default::default()
8637        };
8638        let answer = self.generate(req).await.map_err(|e| e.to_string())?;
8639
8640        // Soft grounding guard: a local model may still name a model family
8641        // outside the evidence. Don't strip mid-sentence (garbles output) —
8642        // flag it, so a hallucinated recommendation can't pass as verified.
8643        const FAMILIES: [&str; 9] = [
8644            "llama", "gpt", "mistral", "gemma", "deepseek", "phi", "claude", "grok", "qwen",
8645        ];
8646        let answer_lc = answer.to_lowercase();
8647        let leaked = FAMILIES
8648            .iter()
8649            .any(|fam| answer_lc.contains(fam) && !evidence_lc.contains(fam));
8650        let answer = if leaked {
8651            format!(
8652                "{answer}\n\n(Note: I can only verify models in your catalog — any others \
8653                 named above aren't checked for fit on your machine.)"
8654            )
8655        } else {
8656            answer
8657        };
8658        Ok(answer)
8659    }
8660
8661    /// Refresh the model catalog from the configured signed source
8662    /// (Phase E1): fetch + verify (detached ed25519 against the pinned
8663    /// key) + cache the verified models. Source is `CAR_CATALOG_URL` +
8664    /// `CAR_CATALOG_PUBKEY` (no key → refused). The new models load into
8665    /// the registry at next startup (the registry is immutable at
8666    /// runtime), then surface as `recommend()` candidates / concierge
8667    /// suggestions. Returns the number of models in the verified catalog.
8668    pub async fn refresh_catalog(&self) -> Result<usize, String> {
8669        let url = std::env::var("CAR_CATALOG_URL")
8670            .map_err(|_| "no catalog source configured (set CAR_CATALOG_URL)".to_string())?;
8671        let pubkey = std::env::var("CAR_CATALOG_PUBKEY")
8672            .map_err(|_| "no catalog public key configured (set CAR_CATALOG_PUBKEY)".to_string())?;
8673        // Not `Client::new()`: that is `build().expect(..)`, which panics when
8674        // the OS trust store loads zero valid certificates. Degrading here is
8675        // safe even for a privately-hosted catalog — authenticity comes from
8676        // the detached ed25519 signature checked below, not from TLS.
8677        let http = crate::tls_client::catalog_refresh_client();
8678        let verified = crate::catalog::fetch_and_verify(&http, &url, &pubkey).await?;
8679        let path = crate::catalog::cache_path(&self.config.state_root);
8680        // Signature verification alone proves authenticity, not freshness.
8681        // Compare the authenticated cached version and atomically replace it
8682        // under one process-local lock so concurrent N/N+1 refreshes cannot
8683        // commit the lower version last.
8684        crate::catalog::install_if_newer(&path, &verified, &pubkey).await
8685    }
8686
8687    /// Auto-discover provider models (Phase E2): query the provider's
8688    /// `/v1/models` list and cache previously-unknown chat/reasoning models as
8689    /// `TrustTier::Community` entries (cloning a curated same-provider schema as
8690    /// a template). Best-effort — no key or no OpenAI provider configured is
8691    /// a no-op, not an error. Discovered models load into the registry at next
8692    /// startup (the registry is immutable at runtime). Returns the total number
8693    /// of cached discovered models. This is what lets the catalog (and the
8694    /// router) pick up new models like a `gpt-5.5` without a release.
8695    pub async fn discover_models(&self) -> Result<usize, String> {
8696        use crate::schema::{ModelSource, TrustTier};
8697        // Template = a curated, remote OpenAI model — gives the endpoint, key
8698        // env, protocol, and routing metadata new entries inherit. `all()` is
8699        // HashMap-ordered (non-deterministic), so pick the MOST-CAPABLE curated
8700        // OpenAI remote (tie-break by id for determinism) rather than the first
8701        // one — otherwise a discovered model could inherit a reduced-capability
8702        // entry like `-mini`.
8703        let template = self
8704            .unified_registry
8705            .all()
8706            .filter(|m| {
8707                m.provider.eq_ignore_ascii_case("openai")
8708                    && m.trust_tier == TrustTier::Curated
8709                    && matches!(m.source, ModelSource::RemoteApi { .. })
8710            })
8711            .max_by(|a, b| {
8712                a.capabilities
8713                    .len()
8714                    .cmp(&b.capabilities.len())
8715                    .then_with(|| a.id.cmp(&b.id))
8716            })
8717            .cloned();
8718        let Some(template) = template else {
8719            return Ok(0); // no OpenAI provider configured → nothing to discover
8720        };
8721        let (endpoint, api_key_env) = match &template.source {
8722            ModelSource::RemoteApi {
8723                endpoint,
8724                api_key_env,
8725                ..
8726            } => (endpoint.clone(), api_key_env.clone()),
8727            _ => return Ok(0),
8728        };
8729        let Some(models_url) = crate::discovery::models_url_from_endpoint(&endpoint) else {
8730            return Ok(0);
8731        };
8732        let key = match car_secrets::resolve_env_or_keychain(&api_key_env) {
8733            Some(k) if !k.is_empty() => k,
8734            _ => return Ok(0), // no key (env or keychain) → best-effort skip
8735        };
8736
8737        // Bounded HTTP: this runs on a background daily timer, so a hung
8738        // provider endpoint must not wedge the loop (which is
8739        // `discover(); sleep(24h)` — a stuck await never reaches the sleep).
8740        let http = reqwest::Client::builder()
8741            .timeout(std::time::Duration::from_secs(20))
8742            .build()
8743            .map_err(|e| format!("discovery client: {e}"))?;
8744        let ids = crate::discovery::fetch_model_ids(&http, &models_url, &key).await?;
8745
8746        // Merge new finds with anything already cached (don't drop prior runs).
8747        let cache = crate::discovery::cache_path(&self.config.state_models_dir());
8748        let mut cached = crate::discovery::load_cache(&cache);
8749        let mut have: std::collections::HashSet<String> =
8750            cached.iter().map(|m| m.id.clone()).collect();
8751        for id in ids {
8752            if !crate::discovery::is_chat_model(&id) {
8753                continue;
8754            }
8755            let schema = crate::discovery::discovered_schema("openai", &id, &template);
8756            // Dedup by the constructed id against BOTH the live registry
8757            // (curated + signed + already-loaded discovered) and this run's
8758            // cache — discovery only ever ADDS ids nothing else owns. Keying on
8759            // the id is more robust than matching the provider's bare id
8760            // against curated `name`s.
8761            if self.unified_registry.get(&schema.id).is_some() {
8762                continue;
8763            }
8764            if have.insert(schema.id.clone()) {
8765                cached.push(schema);
8766            }
8767        }
8768        let count = cached.len();
8769        crate::discovery::save_cache(&cache, &cached)?;
8770        Ok(count)
8771    }
8772
8773    /// All configured lane defaults (Phase D1), from the in-memory cache.
8774    pub fn lane_defaults(&self) -> crate::lane_defaults::LaneDefaults {
8775        self.lane_defaults_cache.read().unwrap().clone()
8776    }
8777
8778    /// Resolve the default model for `(project, use_case)`, if set.
8779    /// Routing consults this as a strong preference before falling back
8780    /// to adaptive selection. Reads the cache (no disk).
8781    pub fn lane_default(
8782        &self,
8783        project: Option<&str>,
8784        use_case: crate::intent::UseCase,
8785    ) -> Option<String> {
8786        self.lane_defaults_cache
8787            .read()
8788            .unwrap()
8789            .resolve(project, use_case)
8790            .map(str::to_string)
8791    }
8792
8793    fn model_is_excluded(
8794        exclude_set: &std::collections::HashSet<String>,
8795        registry: &UnifiedRegistry,
8796        model: &str,
8797    ) -> bool {
8798        registry
8799            .get(model)
8800            .or_else(|| registry.find_by_name(model))
8801            .map(|schema| exclude_set.contains(&schema.id))
8802            .unwrap_or_else(|| exclude_set.contains(model))
8803    }
8804
8805    /// The lane-default model to honor for a request when the caller
8806    /// didn't pin one — `None` unless the request carries a use-case
8807    /// intent whose lane default resolves to a known, available model.
8808    /// A stale/uninstalled pin returns `None` so routing falls through to
8809    /// adaptive selection rather than wedging on a missing model.
8810    fn lane_pin_for(
8811        &self,
8812        req: &GenerateRequest,
8813        routing_registry: &UnifiedRegistry,
8814    ) -> Option<String> {
8815        let task = req.intent.as_ref().and_then(|h| h.task)?;
8816        let use_case = crate::usage_profile::use_case_for_task_hint(task);
8817        let id = self.lane_default(None, use_case)?;
8818        // Validate against the same per-request snapshot the adaptive router
8819        // and dispatch path consume. That snapshot refreshes live credential
8820        // availability for the fixed reviewed registry, so key changes apply
8821        // without allowing an unregistered lane id through.
8822        let known = routing_registry
8823            .get(&id)
8824            .or_else(|| routing_registry.find_by_name(&id));
8825        match known {
8826            Some(s) if s.available_now() => Some(id),
8827            _ => None,
8828        }
8829    }
8830
8831    /// Set the default model for `(project, use_case)` (Phase D1) — the
8832    /// durable target of the concierge's "set it up" action.
8833    pub fn set_lane_default(
8834        &self,
8835        project: Option<String>,
8836        use_case: crate::intent::UseCase,
8837        model_id: &str,
8838    ) -> Result<(), String> {
8839        let now = std::time::SystemTime::now()
8840            .duration_since(std::time::UNIX_EPOCH)
8841            .map(|d| d.as_secs())
8842            .unwrap_or(0);
8843        // Update cache (hot path reads it) + persist, under the cache lock
8844        // so the two stay consistent for the single-writer concierge.
8845        let mut defaults = self.lane_defaults_cache.write().unwrap();
8846        defaults.set(project, use_case, model_id.to_string(), now);
8847        crate::lane_defaults::save_to(&crate::lane_defaults::default_path(), &defaults)
8848            .map_err(|e| e.to_string())
8849    }
8850
8851    /// Clear the default for `(project, use_case)`. Returns whether one
8852    /// existed (used by rollback in D3).
8853    pub fn clear_lane_default(
8854        &self,
8855        project: Option<&str>,
8856        use_case: crate::intent::UseCase,
8857    ) -> Result<bool, String> {
8858        let mut defaults = self.lane_defaults_cache.write().unwrap();
8859        let removed = defaults.clear(project, use_case);
8860        crate::lane_defaults::save_to(&crate::lane_defaults::default_path(), &defaults)
8861            .map_err(|e| e.to_string())?;
8862        Ok(removed)
8863    }
8864
8865    /// User-facing lane-default set (the `concierge.set_default` WS path):
8866    /// like [`set_lane_default`](crate::InferenceEngine::set_lane_default) but serialized under the concierge action
8867    /// lock AND recorded in the action ledger. Without the ledger entry a
8868    /// manual pin would be invisible to the canary, which could then revert
8869    /// it based on a stale ledger view — so this records a `SetDefault`
8870    /// (with the prior captured) exactly like `apply`, keeping the ledger
8871    /// and the live default consistent.
8872    pub async fn user_set_lane_default(
8873        &self,
8874        use_case: crate::intent::UseCase,
8875        model_id: &str,
8876        project: Option<String>,
8877    ) -> Result<(), String> {
8878        use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8879        let _guard = self.concierge_action_lock.lock().await;
8880        let prior = self.lane_default(project.as_deref(), use_case);
8881        self.set_lane_default(project.clone(), use_case, model_id)?;
8882        self.record_concierge_action(ConciergeActionEntry {
8883            seq: 0,
8884            kind: ConciergeActionKind::SetDefault,
8885            model_id: model_id.to_string(),
8886            use_case: Some(use_case),
8887            project,
8888            prior_model_id: prior,
8889            detail: "user set lane default".into(),
8890            timestamp: now_unix(),
8891        });
8892        Ok(())
8893    }
8894
8895    /// User-facing lane-default clear (the `concierge.clear_default` WS
8896    /// path): serialized + ledgered like [`user_set_lane_default`](crate::InferenceEngine::user_set_lane_default). Records
8897    /// a `ClearDefault` so the canary sees the lane is no longer a standing
8898    /// switch (its `latest` action is the clear, not a `SetDefault`).
8899    pub async fn user_clear_lane_default(
8900        &self,
8901        use_case: crate::intent::UseCase,
8902        project: Option<String>,
8903    ) -> Result<bool, String> {
8904        use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8905        let _guard = self.concierge_action_lock.lock().await;
8906        let prior = self.lane_default(project.as_deref(), use_case);
8907        let removed = self.clear_lane_default(project.as_deref(), use_case)?;
8908        if removed {
8909            self.record_concierge_action(ConciergeActionEntry {
8910                seq: 0,
8911                kind: ConciergeActionKind::ClearDefault,
8912                model_id: prior.clone().unwrap_or_default(),
8913                use_case: Some(use_case),
8914                project,
8915                prior_model_id: prior,
8916                detail: "user cleared lane default".into(),
8917                timestamp: now_unix(),
8918            });
8919        }
8920        Ok(removed)
8921    }
8922
8923    /// The recorded concierge actions (Phase D2), most recent last.
8924    pub fn concierge_actions(
8925        &self,
8926        limit: usize,
8927    ) -> Vec<crate::action_ledger::ConciergeActionEntry> {
8928        crate::action_ledger::read_actions(&crate::action_ledger::default_path(), limit)
8929    }
8930
8931    fn record_concierge_action(&self, mut entry: crate::action_ledger::ConciergeActionEntry) {
8932        entry.seq = self
8933            .concierge_action_seq
8934            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8935        if let Err(e) =
8936            crate::action_ledger::append_action(&crate::action_ledger::default_path(), &entry)
8937        {
8938            tracing::debug!("record concierge action failed: {e}");
8939        }
8940    }
8941
8942    /// Closed-loop "set it up" (Phase D3): acquire `model_id`, then set it
8943    /// as the lane default — capturing the prior default so the change is
8944    /// reversible ([`rollback_lane`](crate::InferenceEngine::rollback_lane)). Every step is recorded in the
8945    /// action ledger.
8946    ///
8947    /// Consent: the caller (CarHost) owns the pre-download confirmation —
8948    /// this primitive assumes the user has already agreed to the (possibly
8949    /// multi-GB) download; the ledger entry is the audit record that it
8950    /// happened. Single-writer: lane defaults assume one concierge writer
8951    /// (CarHost); concurrent `apply`s would last-write-wins the JSON (the
8952    /// F3 canary watcher must coordinate before it becomes a 2nd writer).
8953    pub async fn apply_concierge(
8954        &self,
8955        use_case: crate::intent::UseCase,
8956        model_id: &str,
8957        project: Option<String>,
8958    ) -> Result<crate::action_ledger::ConciergeApplyResult, String> {
8959        use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8960        let now = || {
8961            std::time::SystemTime::now()
8962                .duration_since(std::time::UNIX_EPOCH)
8963                .map(|d| d.as_secs())
8964                .unwrap_or(0)
8965        };
8966        // Lane-fit guard: refuse to pin a model that structurally can't
8967        // serve the lane (e.g. a vision-only model on the coding lane).
8968        // A model id unknown to the registry is left to `pull_model` to
8969        // reject as not-found.
8970        if let Some(schema) = self
8971            .list_schemas()
8972            .into_iter()
8973            .find(|s| s.id == model_id || s.name == model_id)
8974        {
8975            let serves = use_case
8976                .required_capabilities()
8977                .iter()
8978                .all(|c| schema.capabilities.contains(c));
8979            if !serves {
8980                return Err(format!(
8981                    "model '{model_id}' does not serve the {use_case:?} lane"
8982                ));
8983            }
8984        }
8985
8986        // 1. Acquire (idempotent — `ensure_local` skips an already-present
8987        //    model; the Install record means "ensured present", not
8988        //    necessarily a fresh download). OUTSIDE the action lock so a
8989        //    long download doesn't block the canary tick.
8990        self.pull_model(model_id).await.map_err(|e| e.to_string())?;
8991
8992        // 2+3 under the action lock: capture prior + record + set default
8993        //    atomically vs. a concurrent canary revert.
8994        let _guard = self.concierge_action_lock.lock().await;
8995        // Capture what we're replacing AFTER taking the lock, so a canary
8996        // revert can't slip in between the read and the write.
8997        let prior = self.lane_default(project.as_deref(), use_case);
8998        self.record_concierge_action(ConciergeActionEntry {
8999            seq: 0, // assigned by record_concierge_action
9000            kind: ConciergeActionKind::Install,
9001            model_id: model_id.to_string(),
9002            use_case: Some(use_case),
9003            project: project.clone(),
9004            prior_model_id: None,
9005            detail: "concierge apply: ensured model present".into(),
9006            timestamp: now(),
9007        });
9008
9009        // 2. Set as the lane default (reversible — prior captured).
9010        self.set_lane_default(project.clone(), use_case, model_id)?;
9011        self.record_concierge_action(ConciergeActionEntry {
9012            seq: 0, // assigned by record_concierge_action
9013            kind: ConciergeActionKind::SetDefault,
9014            model_id: model_id.to_string(),
9015            use_case: Some(use_case),
9016            project: project.clone(),
9017            prior_model_id: prior.clone(),
9018            detail: "concierge apply: set lane default".into(),
9019            timestamp: now(),
9020        });
9021
9022        Ok(crate::action_ledger::ConciergeApplyResult {
9023            model_id: model_id.to_string(),
9024            use_case,
9025            installed: true,
9026            set_default: true,
9027            prior_model_id: prior,
9028        })
9029    }
9030
9031    /// Revert a lane default to its value before the last `apply` (Phase
9032    /// D3 rollback). Restores the prior model (or clears the default if
9033    /// there was none), recording the rollback. Returns the restored
9034    /// model id, or `None` if the default was cleared / nothing to undo.
9035    pub async fn rollback_lane(
9036        &self,
9037        use_case: crate::intent::UseCase,
9038        project: Option<String>,
9039    ) -> Result<Option<String>, String> {
9040        self.rollback_lane_inner(use_case, project, None).await
9041    }
9042
9043    /// Inner rollback: serialized under the concierge action lock so the
9044    /// anchor read + restore + record is atomic vs. a concurrent `apply`.
9045    /// `expected_anchor_ts` (the canary's) makes the revert conditional:
9046    /// if the latest SetDefault is no longer the one we decided on (the
9047    /// user applied a newer switch), refuse rather than undo their choice.
9048    async fn rollback_lane_inner(
9049        &self,
9050        use_case: crate::intent::UseCase,
9051        project: Option<String>,
9052        expected_anchor_seq: Option<u64>,
9053    ) -> Result<Option<String>, String> {
9054        use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
9055        let _guard = self.concierge_action_lock.lock().await;
9056        // One-shot "undo the last apply": anchor on the most recent
9057        // SetDefault *or* Rollback for this (lane, project). If the latest
9058        // is already a Rollback, there's nothing left to undo — refuse
9059        // rather than restore a stale value a second time.
9060        let actions = self.concierge_actions(0);
9061        let anchor = actions.iter().rev().find(|a| {
9062            matches!(
9063                a.kind,
9064                ConciergeActionKind::SetDefault | ConciergeActionKind::Rollback
9065            ) && a.use_case == Some(use_case)
9066                && a.project == project
9067        });
9068        let set = match anchor {
9069            None => return Err("no prior set-default to roll back".into()),
9070            Some(a) if a.kind == ConciergeActionKind::Rollback => {
9071                return Err("already rolled back to the prior default; nothing to undo".into())
9072            }
9073            Some(a) => a,
9074        };
9075        // Conditional revert (canary): only proceed if the anchor is still
9076        // the switch we decided on — the user may have applied a newer one.
9077        if let Some(seq) = expected_anchor_seq {
9078            if set.seq != seq {
9079                return Err("lane default changed since the canary decision; not reverting".into());
9080            }
9081        }
9082        let prior = set.prior_model_id.clone();
9083        let now = std::time::SystemTime::now()
9084            .duration_since(std::time::UNIX_EPOCH)
9085            .map(|d| d.as_secs())
9086            .unwrap_or(0);
9087
9088        match &prior {
9089            Some(m) => self.set_lane_default(project.clone(), use_case, m)?,
9090            None => {
9091                self.clear_lane_default(project.as_deref(), use_case)?;
9092            }
9093        }
9094        let detail = match &prior {
9095            Some(_) => "concierge rollback: restored prior lane default",
9096            None => "concierge rollback: cleared lane default (no prior)",
9097        };
9098        self.record_concierge_action(ConciergeActionEntry {
9099            seq: 0, // assigned by record_concierge_action
9100            kind: ConciergeActionKind::Rollback,
9101            model_id: prior.clone().unwrap_or_default(),
9102            use_case: Some(use_case),
9103            project,
9104            prior_model_id: Some(set.model_id.clone()),
9105            detail: detail.into(),
9106            timestamp: now,
9107        });
9108        Ok(prior)
9109    }
9110
9111    /// Assemble the ambient concierge status (Phase C1): per-lane usage +
9112    /// friction from the outcome ledger, the current grounded decision
9113    /// (`evaluate_concierge`), and per-model health from the profiles. A
9114    /// pull (the UI asks); proactive push stays separate.
9115    pub async fn concierge_status(
9116        &self,
9117        inference_active: bool,
9118    ) -> crate::concierge::ConciergeStatus {
9119        /// Lookback window for the usage profile: 30 days.
9120        const USAGE_WINDOW_SECS: u64 = 30 * 24 * 60 * 60;
9121
9122        let prefs = self.update_prefs();
9123        let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
9124        let hw = crate::hardware::HardwareInfo::detect();
9125        let schemas = self.list_schemas();
9126        let refs: Vec<&ModelSchema> = schemas.iter().collect();
9127        let now = std::time::SystemTime::now()
9128            .duration_since(std::time::UNIX_EPOCH)
9129            .map(|d| d.as_secs())
9130            .unwrap_or(0);
9131
9132        let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9133        let entries = crate::outcome::read_ledger(&ledger_path, 0);
9134        let usage =
9135            crate::usage_profile::UsageProfile::from_ledger(&entries, now, USAGE_WINDOW_SECS);
9136
9137        let decision = crate::concierge::evaluate_concierge(
9138            &refs,
9139            &hw,
9140            &usage,
9141            crate::intent::QualityTier::Balanced,
9142            &prefs,
9143            &state,
9144            now,
9145            crate::concierge::DEFAULT_CONCIERGE_THROTTLE_SECS,
9146            inference_active,
9147        );
9148
9149        let tracker = self.outcome_tracker.read().await;
9150        let models = tracker
9151            .export_profiles()
9152            .iter()
9153            .map(|p| crate::concierge::ModelHealth {
9154                model_id: p.model_id.clone(),
9155                calls: p.total_calls,
9156                // Display-only: `None` when nothing resolved (not the router's
9157                // 0.5 prior), so the UI shows "no resolved signal" rather than
9158                // a misleading "50%" for a never-measured model.
9159                success_rate: p.success_rate_resolved(),
9160                avg_latency_ms: p.avg_latency_ms(),
9161                quality: p.ema_quality,
9162                excluded: tracker.is_excluded(&p.model_id),
9163            })
9164            .collect();
9165        drop(tracker);
9166
9167        // Pending verification: standing switches old enough that we'd
9168        // expect to have verified them, but lacking the resolved samples a
9169        // canary needs (low-resolution lanes). Surface them so the user can
9170        // decide rather than leaving them silently unverifiable.
9171        const STALE_VERIFY_SECS: u64 = 14 * 24 * 60 * 60;
9172        let actions = self.concierge_actions(0);
9173        let mut latest: std::collections::BTreeMap<
9174            crate::intent::UseCase,
9175            &crate::action_ledger::ConciergeActionEntry,
9176        > = std::collections::BTreeMap::new();
9177        for a in &actions {
9178            if a.project.is_none() {
9179                if let Some(uc) = a.use_case {
9180                    latest.insert(uc, a);
9181                }
9182            }
9183        }
9184        let mut pending_verification = Vec::new();
9185        for (uc, a) in latest {
9186            if a.kind != crate::action_ledger::ConciergeActionKind::SetDefault {
9187                continue;
9188            }
9189            if now.saturating_sub(a.timestamp) < STALE_VERIFY_SECS {
9190                continue; // still within the verification window
9191            }
9192            let resolved = entries
9193                .iter()
9194                .filter(|e| {
9195                    e.timestamp >= a.timestamp
9196                        && e.model_id == a.model_id
9197                        && crate::usage_profile::use_case_for_task(e.task) == uc
9198                        && e.success.is_some()
9199                })
9200                .count() as u64;
9201            if resolved < crate::concierge::CANARY_MIN_SAMPLES {
9202                pending_verification.push(crate::concierge::PendingVerification {
9203                    use_case: uc,
9204                    model_id: a.model_id.clone(),
9205                    set_at: a.timestamp,
9206                    resolved_samples: resolved,
9207                    needed: crate::concierge::CANARY_MIN_SAMPLES,
9208                });
9209            }
9210        }
9211
9212        crate::concierge::ConciergeStatus {
9213            lanes: usage.active_lanes().into_iter().cloned().collect(),
9214            decision,
9215            models,
9216            pending_verification,
9217        }
9218    }
9219
9220    /// Detect upgrades combining curated rules with upstream Hub discovery,
9221    /// honoring update preferences (channel/policy) and the TTL cache. Upstream
9222    /// probing only happens on the `Latest` channel and is offline-safe.
9223    pub async fn detect_upgrades(&self) -> Vec<crate::upgrade::UpgradeFinding> {
9224        let prefs = self.update_prefs();
9225        let curated = self.unified_registry.available_upgrades();
9226        let schemas = self.list_schemas();
9227        let refs: Vec<&ModelSchema> = schemas.iter().collect();
9228        let probe = crate::upgrade::HuggingFaceProbe::new();
9229        let now = std::time::SystemTime::now()
9230            .duration_since(std::time::UNIX_EPOCH)
9231            .map(|d| d.as_secs())
9232            .unwrap_or(0);
9233        crate::upgrade::detect_upgrades(
9234            curated,
9235            &refs,
9236            &prefs,
9237            &probe,
9238            &crate::upgrade::UpgradeCache::default_path(),
9239            now,
9240            crate::upgrade::DEFAULT_TTL_SECS,
9241        )
9242        .await
9243    }
9244
9245    /// List all known models and their download status (legacy).
9246    /// List all model schemas from the unified registry (full metadata).
9247    pub fn list_schemas(&self) -> Vec<ModelSchema> {
9248        self.catalog_registry_snapshot()
9249            .list()
9250            .into_iter()
9251            .cloned()
9252            .collect()
9253    }
9254
9255    /// Deterministic immutable catalog view used to bind inference routing to
9256    /// exact model rows. Runtime availability never participates in either
9257    /// row digests or the catalog revision.
9258    pub fn catalog_snapshot(&self) -> Result<CatalogSnapshot, String> {
9259        CatalogSnapshot::new(self.unified_registry.list().into_iter().cloned())
9260    }
9261
9262    /// Return one registered schema without refreshing availability.
9263    ///
9264    /// This is for identity/provenance checks that must reflect signed catalog
9265    /// overrides while remaining local and side-effect free.
9266    pub fn registered_schema(&self, id: &str) -> Option<ModelSchema> {
9267        self.unified_registry.registered_schema(id).cloned()
9268    }
9269
9270    pub fn list_models(&self) -> Vec<models::ModelInfo> {
9271        self.registry.list_models()
9272    }
9273
9274    /// Whether a caller-supplied model name resolves to a registered schema.
9275    ///
9276    /// Answers the question generation asks, by the same two routes and in the
9277    /// same order: exact id, then the case-insensitive name lookup. It is
9278    /// deliberately NOT `list_models()`, which returns the on-device catalog —
9279    /// checking a remote model id against that set reports every cloud model as
9280    /// unknown.
9281    ///
9282    /// Exists so a caller that fans out to several named models can refuse a
9283    /// typo up front instead of discovering it as a generation error per
9284    /// request. Says nothing about whether the model is currently *reachable*
9285    /// (credentials, network) — only that the name is one CAR knows.
9286    pub fn knows_model(&self, name: &str) -> bool {
9287        self.model_schema(name).is_some()
9288    }
9289
9290    /// The registered schema behind a model name or id, resolved exactly as
9291    /// [`Self::knows_model`] resolves it — exact id first, then the
9292    /// case-insensitive name lookup.
9293    ///
9294    /// Defined together with `knows_model` so the two cannot drift into
9295    /// disagreeing about which names exist, and it resolves in the SAME order
9296    /// generation does (`get(id).or_else(find_by_name(id))`, as at the routing
9297    /// sites) — so a caller asking "will these two names reach the same model?"
9298    /// gets the answer that will actually hold at generation time, including
9299    /// `find_by_name`'s MLX-variant redirect on Apple silicon.
9300    ///
9301    /// That fidelity is the point, and it is NOT a canonical identity oracle.
9302    /// The lookup is over a `HashMap`, so if two rows share a display name the
9303    /// one returned is arbitrary — stable within a process, not across
9304    /// restarts. Generation has the same property, so a caller comparing what
9305    /// will run stays correct; a caller needing a stable identity for storage
9306    /// wants the exact id via `registered_schema`.
9307    pub fn model_schema(&self, name: &str) -> Option<&ModelSchema> {
9308        self.unified_registry
9309            .get(name)
9310            .or_else(|| self.unified_registry.find_by_name(name))
9311    }
9312
9313    /// The name of a downloaded, generation-capable on-device model to use as a
9314    /// last-resort fallback so a remote-only chain that fails (an expired cloud
9315    /// credential, an offline network) can still answer locally instead of
9316    /// erroring with nothing left. Prefers the smallest installed model (fastest
9317    /// to load) and excludes dedicated embedding models.
9318    ///
9319    /// When `needs_tools` is set, ONLY an installed model that actually parses
9320    /// tool calls (the `ToolUse` capability) qualifies — a text-only local model
9321    /// would be dropped by the tool-capability guard in the generate loop and
9322    /// help nothing, so returning it as a "fallback" just wastes an attempt.
9323    /// Returns `None` when no installed model can serve the turn (the chain then
9324    /// surfaces the actionable remote error, e.g. the re-authenticate hint).
9325    fn first_installed_local_model(&self, needs_tools: bool) -> Option<String> {
9326        // Enumerate through the UNIFIED registry, not the legacy GGUF-only
9327        // `list_models()`. The legacy catalog keys `downloaded` off a
9328        // `{name}/model.gguf` file, so it is blind to MLX installs (stored as
9329        // config.json + safetensors, no `.gguf`) — i.e. every model on Apple
9330        // Silicon, the platform where degrading to on-device matters most. The
9331        // unified registry's `ready_without_download` understands both the GGUF
9332        // and MLX layouts, so this fallback fires on macOS too.
9333        let mut candidates: Vec<_> = self
9334            .unified_registry
9335            .all()
9336            // In-process on-device backends ONLY (GGUF via candle, or in-process
9337            // MLX) — the same set `ensure_local_backend` drives. `is_local()`
9338            // also matches `VllmMlx`, whose `ready_without_download` is
9339            // unconditionally true but which needs an external vLLM-MLX server
9340            // that is usually not running (and never on Windows/Linux); picking
9341            // one would be a dead fallback, not an on-device answer.
9342            .filter(|s| s.is_local() && !s.is_vllm_mlx())
9343            // Apple's FoundationModels reports `ready_without_download == true`
9344            // on every platform (there is nothing to download), but it only
9345            // EXECUTES on Apple Silicon — off-Apple its `available` is false, a
9346            // platform-static fact, so the registry's boot-time value is reliable
9347            // here even though the frozen registry is otherwise untrustworthy for
9348            // availability (car#651). Without this, a remote-only fallback chain
9349            // on Windows/Linux appends `apple-foundation` as a "local last
9350            // resort", it fails with `model not found: apple-foundation`, and that
9351            // error MASKS the real remote failure (a Windows CRLF-broken fixture
9352            // surfaced exactly this). Scoped to this source on purpose: other
9353            // local models' availability CAN change at runtime (a GGUF pulled
9354            // after boot), which is why the readiness gate below stays
9355            // `ready_without_download`, not `available`. `is_foundation_models`'s
9356            // own docs say callers must verify runtime availability before
9357            // dispatch — this is that check.
9358            .filter(|s| !s.is_foundation_models() || s.available)
9359            .filter(|s| s.has_capability(ModelCapability::Generate))
9360            // A tools-bearing turn needs a model that actually parses tool calls
9361            // (ToolUse); a text-only local model would be dropped by the
9362            // generate loop's capability guard and waste an attempt.
9363            .filter(|s| !needs_tools || s.has_capability(ModelCapability::ToolUse))
9364            .filter(|s| self.model_management.car_enabled(&s.id).unwrap_or(false))
9365            .filter(|s| self.unified_registry.ready_without_download(&s.id) == Some(true))
9366            .collect();
9367        // Smallest first — fastest to load for a last-resort answer.
9368        candidates.sort_by_key(|s| s.size_mb());
9369
9370        // Don't hand back a model this machine can't actually run RIGHT NOW.
9371        // The last-resort fallback fires when a remote call fails, and that
9372        // often coincides with a loaded machine; picking a local model without
9373        // the free RAM to run it turns a recoverable remote error into a hard
9374        // Metal OOM abort mid-generation (observed: a transient Parslee outage
9375        // during a browser-automation run fell back to on-device and crashed
9376        // with a 48 GB `[metal::malloc]` allocation on a box with ~2 GB free).
9377        // The routing-time `fits_now` guard covers model SELECTION; this covers
9378        // the fallback-APPEND path, which bypasses it. Weights plus a working
9379        // reserve for the KV cache and activations must fit in available RAM.
9380        // If availability can't be read, keep prior behavior (append anyway).
9381        if let Some(avail) = crate::hardware::available_ram_mb() {
9382            const WORKING_RESERVE_MB: u64 = 2048;
9383            candidates.retain(|s| s.size_mb().saturating_add(WORKING_RESERVE_MB) <= avail);
9384        }
9385
9386        candidates.first().map(|s| s.name.clone())
9387    }
9388
9389    /// Download a model if not already present.
9390    pub async fn pull_model(&self, name: &str) -> Result<std::path::PathBuf, InferenceError> {
9391        self.pull_model_with_progress(name, &crate::download::ProgressSink::none())
9392            .await
9393    }
9394
9395    /// Download a model if not already present, reporting progress to `sink`
9396    /// and enforcing the acquisition lifecycle (per-model lock, disk preflight,
9397    /// lifecycle events). The CLI and daemon use this to show live progress.
9398    pub async fn pull_model_with_progress(
9399        &self,
9400        name: &str,
9401        sink: &crate::download::ProgressSink,
9402    ) -> Result<std::path::PathBuf, InferenceError> {
9403        let schema = self
9404            .unified_registry
9405            .find_by_name(name)
9406            .or_else(|| self.unified_registry.get(name))
9407            .ok_or_else(|| InferenceError::ModelNotFound(name.to_string()))?;
9408        let _mutation = self.model_management.begin_mutation(&schema.id)?;
9409        if let Some(receipt) = self.model_management.load_receipt(&schema.id)? {
9410            // `can_remove=false` can mean a valid receipt-backed directory
9411            // whose recursive cleanup is deliberately unsupported. Removal
9412            // capability must never gate reuse/re-enable of valid weights.
9413            let _ = self.model_management.can_remove(&schema.id)?;
9414            self.model_management.clear_tombstone(&schema.id)?;
9415            return Ok(receipt.managed_path);
9416        }
9417        if let Some(receipt) = self.model_management.resume_install_intent(&schema.id)? {
9418            return Ok(receipt.managed_path);
9419        }
9420        let expected_managed = self.model_management.models_dir().join(&schema.name);
9421        if std::fs::symlink_metadata(&expected_managed).is_ok() {
9422            return Err(InferenceError::InferenceFailed(format!(
9423                "model {} has a pre-existing unreceipted artifact at {}; use models.adopt or move it before pulling",
9424                schema.id,
9425                expected_managed.display()
9426            )));
9427        }
9428        let staging = self.model_management.create_install_staging(&schema.id)?;
9429        let installed = match self
9430            .unified_registry
9431            .ensure_local_with_progress_staged(&schema.id, sink, &staging)
9432            .await
9433        {
9434            Ok(installed) => installed,
9435            Err(error) => {
9436                self.model_management.discard_install_staging(&staging);
9437                return Err(error);
9438            }
9439        };
9440        let installed_is_staging = match (installed.canonicalize(), staging.canonicalize()) {
9441            (Ok(installed), Ok(staging)) => installed == staging,
9442            _ => false,
9443        };
9444        let generation = self
9445            .resource_policy_generation
9446            .load(std::sync::atomic::Ordering::Acquire);
9447        let managed_path = self.model_management.models_dir().join(&schema.name);
9448        let managed = if installed_is_staging {
9449            let receipt = self.model_management.install_receipt_for_publication(
9450                &schema.id,
9451                model_source_identity(schema),
9452                generation,
9453                false,
9454                managed_path,
9455                model_management::ManagedArtifactKind::Directory,
9456                &staging,
9457            )?;
9458            self.model_management
9459                .begin_install_intent(receipt, Some(&staging))?;
9460            self.model_management
9461                .publish_install_staging(&schema.id, &staging, &schema.name)?
9462        } else {
9463            self.model_management.discard_install_staging(&staging);
9464            let receipt = self.model_management.install_receipt_for_publication(
9465                &schema.id,
9466                model_source_identity(schema),
9467                generation,
9468                false,
9469                managed_path,
9470                model_management::ManagedArtifactKind::Symlink,
9471                &installed,
9472            )?;
9473            self.model_management.begin_install_intent(receipt, None)?;
9474            self.model_management
9475                .materialize_managed_projection(&schema.name, &installed)?
9476        };
9477        let receipt = self
9478            .model_management
9479            .resume_install_intent(&schema.id)?
9480            .ok_or_else(|| {
9481                InferenceError::InferenceFailed(format!(
9482                    "model {} publication completed without a durable install intent",
9483                    schema.id
9484                ))
9485            })?;
9486        debug_assert_eq!(receipt.managed_path, managed);
9487        Ok(receipt.managed_path)
9488    }
9489
9490    /// Explicitly adopt an already-usable local artifact into CAR ownership.
9491    /// The source path is resolved from the registry; callers cannot nominate
9492    /// an arbitrary deletion target.
9493    pub async fn adopt_model_into_car(
9494        &self,
9495        model_id: &str,
9496    ) -> Result<model_management::InstallReceipt, InferenceError> {
9497        let schema = self
9498            .unified_registry
9499            .get(model_id)
9500            .or_else(|| self.unified_registry.find_by_name(model_id))
9501            .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
9502        if !schema.is_local() || !schema.downloads_weights() {
9503            return Err(InferenceError::InferenceFailed(format!(
9504                "model {} is not a CAR-manageable local artifact",
9505                schema.id
9506            )));
9507        }
9508        let existing = self
9509            .unified_registry
9510            .existing_local_artifact(&schema.id)
9511            .ok_or_else(|| {
9512                InferenceError::InferenceFailed(format!(
9513                    "model {} has no usable local artifact to adopt",
9514                    schema.id
9515                ))
9516            })?;
9517        let _mutation = self.model_management.begin_mutation(&schema.id)?;
9518        let existing_is_managed_symlink = std::fs::symlink_metadata(&existing)
9519            .is_ok_and(|metadata| metadata.file_type().is_symlink())
9520            && existing.parent().is_some_and(|parent| {
9521                parent.canonicalize().ok() == self.model_management.models_dir().canonicalize().ok()
9522            });
9523        if let Some(receipt) = self.model_management.resume_install_intent(&schema.id)? {
9524            return Ok(receipt);
9525        }
9526        let generation = self
9527            .resource_policy_generation
9528            .load(std::sync::atomic::Ordering::Acquire);
9529        let managed = if existing_is_managed_symlink {
9530            existing
9531        } else {
9532            let managed = self.model_management.adopted_projection_path(&schema.id)?;
9533            let receipt = self.model_management.install_receipt_for_publication(
9534                &schema.id,
9535                model_source_identity(schema),
9536                generation,
9537                true,
9538                managed,
9539                model_management::ManagedArtifactKind::Symlink,
9540                &existing,
9541            )?;
9542            self.model_management.begin_install_intent(receipt, None)?;
9543            self.model_management
9544                .materialize_adopted_projection(&schema.id, &existing)?
9545        };
9546        if existing_is_managed_symlink {
9547            self.model_management
9548                .record_managed_artifact(
9549                    &schema.id,
9550                    model_source_identity(schema),
9551                    None,
9552                    generation,
9553                    true,
9554                    managed,
9555                )
9556                .map_err(InferenceError::from)
9557        } else {
9558            self.model_management
9559                .resume_install_intent(&schema.id)?
9560                .ok_or_else(|| {
9561                    InferenceError::InferenceFailed(format!(
9562                        "model {} adoption completed without a durable install intent",
9563                        schema.id
9564                    ))
9565                })
9566        }
9567    }
9568
9569    /// Safely remove only CAR-owned linkage after every Task 3 runtime owner
9570    /// acknowledges release. Shared Hugging Face blobs remain untouched.
9571    pub async fn remove_model_from_car(
9572        &self,
9573        model_id: &str,
9574    ) -> Result<model_management::RemoveFromCarResult, InferenceError> {
9575        let schema = self
9576            .unified_registry
9577            .get(model_id)
9578            .or_else(|| self.unified_registry.find_by_name(model_id))
9579            .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
9580        if !schema.is_local() || !schema.downloads_weights() {
9581            return Err(InferenceError::InferenceFailed(format!(
9582                "model {} is not a CAR-owned local artifact",
9583                schema.id
9584            )));
9585        }
9586        if self.model_management.load_receipt(&schema.id)?.is_none() {
9587            if self.model_management.car_enabled(&schema.id)? {
9588                return Err(model_management::ModelManagementError::MissingReceipt {
9589                    model_id: schema.id.clone(),
9590                }
9591                .into());
9592            }
9593            // A prior request may have completed removal and lost its response.
9594            // Resume the durable tombstone result without re-running runtime
9595            // maintenance against an artifact that no longer exists.
9596            let generation = self
9597                .resource_policy_generation
9598                .load(std::sync::atomic::Ordering::Acquire)
9599                .saturating_add(1);
9600            return self
9601                .model_management
9602                .begin_mutation(&schema.id)?
9603                .remove(generation)
9604                .map_err(InferenceError::from);
9605        }
9606        let mutation = self.model_management.begin_mutation(&schema.id)?;
9607        let _maintenance = self
9608            .prepare_local_model_removal(&schema.id)
9609            .await
9610            .map_err(|error| InferenceError::InferenceFailed(error.to_string()))?;
9611        let generation = self
9612            .resource_policy_generation
9613            .load(std::sync::atomic::Ordering::Acquire)
9614            .saturating_add(1);
9615        mutation.remove(generation).map_err(InferenceError::from)
9616    }
9617
9618    /// Current update preferences. A team-shared project `.car/update-prefs.json`
9619    /// (found by walking up from cwd) overrides the user `~/.car/update-prefs.json`;
9620    /// defaults if neither exists. Loaded on demand — read at onboarding/
9621    /// upgrade-check frequency, not on the inference hot path.
9622    pub fn update_prefs(&self) -> crate::update_prefs::UpdatePreferences {
9623        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
9624        crate::update_prefs::UpdatePreferences::load_effective(&cwd).unwrap_or_default()
9625    }
9626
9627    /// Persist update preferences to `~/.car/update-prefs.json`.
9628    pub fn set_update_prefs(
9629        &self,
9630        prefs: &crate::update_prefs::UpdatePreferences,
9631    ) -> Result<(), InferenceError> {
9632        prefs.save().map_err(InferenceError::InferenceFailed)
9633    }
9634
9635    /// Legacy synchronous removal is intentionally disabled because it cannot
9636    /// coordinate active workers, cross-process leases, or receipt ownership.
9637    /// Use [`Self::remove_model_from_car`] instead.
9638    #[deprecated(note = "use async remove_model_from_car for receipt-backed safe removal")]
9639    pub fn remove_model(&self, name: &str) -> Result<(), InferenceError> {
9640        Err(InferenceError::InferenceFailed(format!(
9641            "legacy removal for {name} is disabled; use async receipt-backed remove_model_from_car"
9642        )))
9643    }
9644
9645    /// Register a model at the public runtime boundary.
9646    ///
9647    /// The registry normalizes every such schema to Community trust. Project
9648    /// curation is reserved for compiled builtins and signature-verified
9649    /// catalogs inside this crate.
9650    pub fn register_model(&mut self, schema: ModelSchema) {
9651        self.unified_registry.register(schema);
9652    }
9653
9654    /// Register a model from a user-controlled schema boundary.
9655    pub fn register_user_model(&mut self, schema: ModelSchema) {
9656        self.unified_registry.register_user_model(schema);
9657    }
9658
9659    /// Discover generic MLX models from a running vLLM-MLX server and register them.
9660    /// Returns the number of discovered models added or refreshed in the registry.
9661    pub async fn discover_vllm_mlx_models(&mut self) -> usize {
9662        let config = vllm_mlx::VllmMlxConfig::default();
9663        if !config.auto_discover {
9664            return 0;
9665        }
9666        vllm_mlx::discover_and_register(&config, &mut self.unified_registry).await
9667    }
9668
9669    /// Get outcome tracker for external use (e.g., memgine integration).
9670    pub fn outcome_tracker(&self) -> Arc<RwLock<OutcomeTracker>> {
9671        self.outcome_tracker.clone()
9672    }
9673
9674    /// Auto-save outcomes and key pool stats silently (called after every
9675    /// inference call). Debounced: the outcome profiles are only written
9676    /// when the tracker is dirty AND at least `OUTCOME_FLUSH_INTERVAL` has
9677    /// passed since the last flush — so a busy machine doesn't serialize
9678    /// and rewrite the whole profiles file on every single call. A forced,
9679    /// unconditional flush is available via [`save_outcomes`] (used on
9680    /// shutdown / by the dream task).
9681    async fn auto_save_outcomes(&self) {
9682        const OUTCOME_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
9683
9684        // An on-device inference worker (car-releases#74) is a stateless compute
9685        // slave: the parent daemon owns outcome bookkeeping and persistence for
9686        // every offloaded call (it wraps the offload in its own record_start /
9687        // record_complete). If the worker also wrote `outcome_profiles.json` /
9688        // `outcome_ledger.jsonl` in the shared models_dir it would race the
9689        // daemon's atomic writes and clobber the router's learning with a
9690        // local-only view. So the worker never persists.
9691        if crate::offload::is_offload_worker() {
9692            return;
9693        }
9694
9695        // Read the debounce gate without holding the lock across an await.
9696        let due = {
9697            let last = self.last_outcome_flush.lock().unwrap();
9698            last.is_none_or(|t| t.elapsed() >= OUTCOME_FLUSH_INTERVAL)
9699        };
9700        if due {
9701            match self.persist_outcomes().await {
9702                Ok(did) => {
9703                    if did {
9704                        *self.last_outcome_flush.lock().unwrap() = Some(Instant::now());
9705                    }
9706                }
9707                Err(e) => tracing::debug!("auto-save outcomes failed: {}", e),
9708            }
9709        }
9710
9711        if let Err(e) = self.save_key_pool_stats().await {
9712            tracing::debug!("auto-save key pool stats failed: {}", e);
9713        }
9714    }
9715
9716    /// Persist both outcome artifacts: append the resolved-outcome ledger
9717    /// (the durable, attributable receipts — append-only JSONL) and save
9718    /// the derived aggregate profiles (dirty-gated, atomic). Returns
9719    /// whether anything was written. Shared by the debounced per-call path
9720    /// and the immediate [`flush_outcomes`] backstop.
9721    async fn persist_outcomes(&self) -> Result<bool, std::io::Error> {
9722        const PENDING_TTL_SECS: u64 = 300;
9723        // Under one write lock: evict stale pending (bounds memory +
9724        // de-biases the ledger via Inconclusive receipts) and drain the
9725        // resulting receipts.
9726        let entries = {
9727            let mut tracker = self.outcome_tracker.write().await;
9728            tracker.sweep_pending(PENDING_TTL_SECS);
9729            tracker.drain_ledger()
9730        };
9731        let mut did = false;
9732        // Privacy opt-out: CAR_NO_OUTCOME_LEDGER drops per-call receipts
9733        // entirely (the buffer is still drained so it can't grow). Aggregate
9734        // profiles still persist — routing needs them — but no attributable
9735        // per-call record is written.
9736        let ledger_disabled = std::env::var_os("CAR_NO_OUTCOME_LEDGER").is_some();
9737        if !entries.is_empty() && !ledger_disabled {
9738            let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9739            let _guard = self.ledger_io_lock.lock().await;
9740            crate::outcome::append_ledger_entries(&ledger_path, &entries)?;
9741            did = true;
9742        }
9743        // Profiles: dirty-gated atomic save.
9744        let profiles_path = self.config.state_models_dir().join("outcome_profiles.json");
9745        let wrote = {
9746            let mut tracker = self.outcome_tracker.write().await;
9747            tracker.save_if_dirty(&profiles_path)?
9748        };
9749        Ok(did || wrote)
9750    }
9751
9752    /// Persist outcome profiles to disk for cross-session learning (#13).
9753    /// Unconditional (force) save — writes even if nothing changed.
9754    /// Prefer [`flush_outcomes`](crate::InferenceEngine::flush_outcomes) for shutdown / periodic flushes; the
9755    /// per-call path uses [`auto_save_outcomes`](crate::InferenceEngine::auto_save_outcomes), which debounces.
9756    pub async fn save_outcomes(&self) -> Result<(), std::io::Error> {
9757        let tracker = self.outcome_tracker.read().await;
9758        let path = self.config.state_models_dir().join("outcome_profiles.json");
9759        tracker.save_to_file(&path)
9760    }
9761
9762    /// Flush outcome profiles to disk **iff** dirty, ignoring the per-call
9763    /// time debounce. Returns whether a write happened. This is the
9764    /// durable-receipt backstop: the daemon calls it on a periodic timer
9765    /// and on graceful shutdown so the last (sub-`OUTCOME_FLUSH_INTERVAL`)
9766    /// window of learning is never lost. Cheap when clean (no write).
9767    pub async fn flush_outcomes(&self) -> Result<bool, std::io::Error> {
9768        let did = self.persist_outcomes().await?;
9769        if did {
9770            *self.last_outcome_flush.lock().unwrap() = Some(Instant::now());
9771        }
9772        Ok(did)
9773    }
9774
9775    /// Enforce the outcome-ledger retention bound (privacy + disk). A cheap
9776    /// no-op when under the cap; the daemon calls it periodically.
9777    pub async fn prune_outcome_ledger(&self, max_entries: usize) -> std::io::Result<()> {
9778        let path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9779        let _guard = self.ledger_io_lock.lock().await;
9780        crate::outcome::prune_ledger(&path, max_entries)
9781    }
9782
9783    /// Persist key pool stats to disk.
9784    pub async fn save_key_pool_stats(&self) -> Result<(), std::io::Error> {
9785        let path = self.config.state_models_dir().join("key_pool_stats.json");
9786        self.remote_backend.key_pool.save_stats(&path).await
9787    }
9788
9789    /// Get key pool stats for all endpoints.
9790    pub async fn key_pool_stats(
9791        &self,
9792    ) -> std::collections::HashMap<String, Vec<key_pool::KeyStats>> {
9793        self.remote_backend.key_pool.all_stats().await
9794    }
9795
9796    /// Export model performance profiles for persistence.
9797    pub async fn export_profiles(&self) -> Vec<ModelProfile> {
9798        let tracker = self.outcome_tracker.read().await;
9799        tracker.export_profiles()
9800    }
9801
9802    /// Fold the durable outcome ledger into the deployment scoreboard — the
9803    /// per-model, priced, OUTCOME-DENOMINATED view (cost-per-success,
9804    /// tokens-per-success, success-rate). Reads the same `outcome_ledger.jsonl`
9805    /// the tracker flushes to (cross-session, survives restart) and joins
9806    /// per-model catalog prices from the registry so `usd_per_success` is the
9807    /// honest "cry once" figure. Unpriced models keep a `None` dollar figure
9808    /// rather than a fabricated one. See [`crate::scoreboard::Scoreboard`].
9809    pub fn outcome_scoreboard(&self) -> crate::scoreboard::Scoreboard {
9810        let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9811        let entries = crate::outcome::read_ledger(&ledger_path, 0);
9812        // #369: shadow-calibration telemetry folds the SAME durable ledger —
9813        // surface how the router's quality constants would tune as graded
9814        // evidence accumulates, without touching the live constants or routing.
9815        crate::calibration::ShadowCalibration::from_ledger(&entries).emit();
9816        crate::scoreboard::Scoreboard::from_ledger(&entries, |id| {
9817            let s = self
9818                .unified_registry
9819                .get(id)
9820                .or_else(|| self.unified_registry.find_by_name(id))?;
9821            match (s.cost.input_per_mtok, s.cost.output_per_mtok) {
9822                // Cache economics come from the model's protocol so cached
9823                // tokens are priced at the right per-provider discount
9824                // (Anthropic 0.1×/1.25×, OpenAI 0.5×/no-write).
9825                (Some(input_per_mtok), Some(output_per_mtok)) => {
9826                    Some(crate::scoreboard::PriceModel {
9827                        input_per_mtok,
9828                        output_per_mtok,
9829                        cache: s.cache_rates(),
9830                        is_estimate: !s.cost.pricing_tiers.is_empty(),
9831                    })
9832                }
9833                _ => None,
9834            }
9835        })
9836    }
9837
9838    /// Import model performance profiles (from persistence).
9839    pub async fn import_profiles(&self, profiles: Vec<ModelProfile>) {
9840        let mut tracker = self.outcome_tracker.write().await;
9841        tracker.import_profiles(profiles);
9842    }
9843
9844    /// Ensure the managed local speech runtime exists and return its root
9845    /// directory — the same root [`speech_health`](Self::speech_health)
9846    /// reports, on every platform.
9847    ///
9848    /// Apple Silicon used to short-circuit here: native MLX backends were taken
9849    /// to replace the Python runtime outright, so this only created
9850    /// `models_dir` and handed *that* back without ever provisioning the
9851    /// managed runtime. Since #640 the runtime is a live fallback there too
9852    /// (the native backends can't load every catalogued checkpoint) and
9853    /// `speech doctor` reports its real state — so a "successful" install
9854    /// contradicted doctor, printed a path doctor never mentions, and pushed
9855    /// the multi-minute venv+pip bootstrap onto the first synthesis
9856    /// (Parslee-ai/car#649). Provision it up front on every platform instead.
9857    ///
9858    /// The one asymmetry that remains is what a bootstrap *failure* means.
9859    /// Off Apple Silicon the managed runtime is the only local speech path, so
9860    /// failing to build it fails the call. On Apple Silicon it sits behind
9861    /// working native backends, so a missing `uv` degrades rather than breaks:
9862    /// the root comes back either way, and callers should report
9863    /// `speech_health().runtime.installed` rather than read a returned path as
9864    /// proof of success. Either way the returned directory exists — a method
9865    /// called "prepare" leaves the thing prepared (Parslee-ai/car#626).
9866    pub async fn prepare_speech_runtime(&self) -> Result<PathBuf, InferenceError> {
9867        match self.ensure_speech_runtime().await {
9868            Ok(runtime) => Ok(runtime.root),
9869            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
9870            Err(err) => {
9871                let root = speech_runtime_root_from_models_dir(&self.config.models_dir);
9872                tracing::warn!(
9873                    error = %err,
9874                    root = %root.display(),
9875                    "managed speech runtime could not be provisioned; native MLX \
9876                     backends still cover the default local models, but catalogued \
9877                     checkpoints they cannot load will be unavailable"
9878                );
9879                std::fs::create_dir_all(&root)?;
9880                Ok(root)
9881            }
9882            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
9883            Err(err) => Err(err),
9884        }
9885    }
9886
9887    /// Override speech routing preferences for the current engine instance.
9888    pub fn set_speech_policy(&mut self, policy: SpeechPolicy) {
9889        self.speech_policy = policy;
9890    }
9891
9892    pub fn set_routing_config(&mut self, config: RoutingConfig) {
9893        self.adaptive_router.set_config(config);
9894    }
9895
9896    /// Download the curated local speech model set into the shared Hugging Face cache.
9897    pub async fn install_curated_speech(
9898        &mut self,
9899    ) -> Result<Vec<SpeechInstallReport>, InferenceError> {
9900        // Provisioning the managed runtime is best-effort here, deliberately.
9901        // It is an Apple-only Python stack (`uv venv` + `pip install
9902        // mlx-audio`, and `mlx` publishes no Windows/Linux wheels), and off
9903        // Apple Silicon `prepare_speech_runtime` returns `Err` whenever it
9904        // cannot be built. Propagating that aborted the whole command before
9905        // the whisper.cpp block below — the one local speech model that *does*
9906        // run on Windows and Linux, and the one `car speech doctor` tells
9907        // users to run this command for (car#678). A runtime that could not be
9908        // provisioned is reported by `speech_health().runtime.installed`,
9909        // which the CLI prints; it is not a reason to skip the models.
9910        // `speech.prepare` still fails loudly, since provisioning the runtime
9911        // is that call's entire job.
9912        if let Err(error) = self.prepare_speech_runtime().await {
9913            tracing::warn!(
9914                %error,
9915                "managed speech runtime could not be provisioned; installing the \
9916                 models that do not depend on it"
9917            );
9918        }
9919        let schemas = self.list_schemas();
9920        let mut repos = Vec::new();
9921        for schema in &schemas {
9922            if !schema.is_mlx() || !schema.tags.iter().any(|tag| tag == "speech") {
9923                continue;
9924            }
9925            // MLX runs on Apple Silicon only — the registry marks every MLX
9926            // schema unavailable elsewhere. Without this the command would
9927            // pull well over a gigabyte of Kokoro / Parakeet / Qwen3-TTS
9928            // weights onto a Windows or Linux box that can never load them,
9929            // which only became reachable once the abort above was removed.
9930            if !schema.available {
9931                continue;
9932            }
9933            if let ModelSource::Mlx { hf_repo, .. } = &schema.source {
9934                if !repos.iter().any(|existing: &String| existing == hf_repo) {
9935                    repos.push(hf_repo.clone());
9936                }
9937            }
9938        }
9939
9940        let mut installed = Vec::new();
9941        for repo in repos {
9942            let (snapshot_path, files_downloaded) = download_hf_repo_snapshot(&repo).await?;
9943            let name = schemas
9944                .iter()
9945                .find(|schema| {
9946                    matches!(&schema.source, ModelSource::Mlx { hf_repo, .. } if hf_repo == &repo)
9947                })
9948                .map(|schema| schema.name.clone())
9949                .unwrap_or_else(|| repo.clone());
9950            installed.push(SpeechInstallReport {
9951                name,
9952                hf_repo: repo,
9953                snapshot_path,
9954                files_downloaded,
9955            });
9956        }
9957
9958        // whisper.cpp catalog entries (cross-platform local STT) fetch their
9959        // ggml model from ggerganov/whisper.cpp into ~/.tokhn/whisper/ — a
9960        // different store than the MLX HF snapshots above, so install it here.
9961        for schema in &schemas {
9962            if !schema.tags.iter().any(|tag| tag == "speech") {
9963                continue;
9964            }
9965            if let ModelSource::WhisperCpp { model } = &schema.source {
9966                let model = model.clone();
9967                let name = schema.name.clone();
9968                let path = tokio::task::spawn_blocking(move || car_whisper::ensure_model(&model))
9969                    .await
9970                    .map_err(|e| InferenceError::InferenceFailed(format!("whisper join: {e}")))?
9971                    .map_err(|e| {
9972                        InferenceError::InferenceFailed(format!("whisper model fetch: {e}"))
9973                    })?;
9974                installed.push(SpeechInstallReport {
9975                    name,
9976                    hf_repo: "ggerganov/whisper.cpp".to_string(),
9977                    snapshot_path: path,
9978                    files_downloaded: 1,
9979                });
9980            }
9981        }
9982
9983        self.unified_registry.refresh_availability();
9984        Ok(installed)
9985    }
9986
9987    /// Report speech runtime, model cache, and remote-provider health.
9988    pub fn speech_health(&self) -> SpeechHealthReport {
9989        let local_stt_default =
9990            self.speech_health_default_name(ModelCapability::SpeechToText, true, false);
9991        let local_tts_default =
9992            self.speech_health_default_name(ModelCapability::TextToSpeech, true, false);
9993        let remote_stt_default =
9994            self.speech_health_default_name(ModelCapability::SpeechToText, false, true);
9995        let remote_tts_default =
9996            self.speech_health_default_name(ModelCapability::TextToSpeech, false, true);
9997
9998        let mut local_models = Vec::new();
9999        let mut remote_models = Vec::new();
10000        for schema in self.list_schemas() {
10001            let capability = if schema.has_capability(ModelCapability::SpeechToText) {
10002                Some(ModelCapability::SpeechToText)
10003            } else if schema.has_capability(ModelCapability::TextToSpeech) {
10004                Some(ModelCapability::TextToSpeech)
10005            } else {
10006                None
10007            };
10008            let Some(capability) = capability else {
10009                continue;
10010            };
10011
10012            let selected_by_default = local_stt_default
10013                .as_ref()
10014                .is_some_and(|name| name == &schema.name)
10015                || local_tts_default
10016                    .as_ref()
10017                    .is_some_and(|name| name == &schema.name)
10018                || remote_stt_default
10019                    .as_ref()
10020                    .is_some_and(|name| name == &schema.name)
10021                || remote_tts_default
10022                    .as_ref()
10023                    .is_some_and(|name| name == &schema.name);
10024
10025            let health = SpeechModelHealth {
10026                id: schema.id.clone(),
10027                name: schema.name.clone(),
10028                provider: schema.provider.clone(),
10029                capability,
10030                is_local: schema.is_local(),
10031                available: schema.available,
10032                cached: speech_model_cached(&schema),
10033                selected_by_default,
10034                source: speech_model_source_label(&schema),
10035            };
10036            if schema.is_local() {
10037                local_models.push(health);
10038            } else {
10039                remote_models.push(health);
10040            }
10041        }
10042
10043        // Report the real managed-runtime state on every platform. Apple
10044        // Silicon used to fabricate `installed: true` with empty paths on the
10045        // theory that native MLX backends replaced the Python runtime — but
10046        // those backends can't load every catalogued speech checkpoint, so the
10047        // runtime is a live fallback there too and `car speech doctor` should
10048        // say whether it is actually present (Parslee-ai/car#640).
10049        let runtime = {
10050            let rt =
10051                SpeechRuntime::new(speech_runtime_root_from_models_dir(&self.config.models_dir));
10052            SpeechRuntimeHealth {
10053                root: rt.root.clone(),
10054                installed: rt.is_ready(),
10055                python: rt.python.clone(),
10056                stt_command: rt.stt_program.clone(),
10057                tts_command: rt.tts_program.clone(),
10058                configured_python: std::env::var("CAR_SPEECH_PYTHON")
10059                    .ok()
10060                    .filter(|value| !value.trim().is_empty()),
10061                detected_python: detect_speech_python(),
10062            }
10063        };
10064
10065        SpeechHealthReport {
10066            runtime,
10067            local_models,
10068            remote_models,
10069            // Passive doctor/health state may report explicit environment
10070            // presence, but must not query the OS credential store. A pasted
10071            // Keychain value remains unknown until an explicit use/status.
10072            elevenlabs_configured: crate::tasks::transcribe::provider_configured_for_passive_status(
10073                "ELEVENLABS_API_KEY",
10074            )
10075                || crate::tasks::synthesize::provider_configured_for_passive_status(
10076                    "ELEVENLABS_API_KEY",
10077                ),
10078            prefer_local: self.speech_policy.prefer_local,
10079            allow_remote_fallback: self.speech_policy.allow_remote_fallback,
10080            preferred_local_stt: self.speech_policy.preferred_local_stt.clone(),
10081            preferred_local_tts: self.speech_policy.preferred_local_tts.clone(),
10082            preferred_remote_stt: self.speech_policy.preferred_remote_stt.clone(),
10083            preferred_remote_tts: self.speech_policy.preferred_remote_tts.clone(),
10084            local_stt_default,
10085            local_tts_default,
10086            remote_stt_default,
10087            remote_tts_default,
10088        }
10089    }
10090
10091    /// Report the current model catalog, configured defaults, capability coverage,
10092    /// and speech runtime/provider health in one place.
10093    pub async fn model_health(&self) -> ModelHealthReport {
10094        let schemas = self.list_schemas();
10095        let total_models = schemas.len();
10096        let available_models = schemas
10097            .iter()
10098            .filter(|schema| schema.available_now())
10099            .count();
10100        let local_models = schemas.iter().filter(|schema| schema.is_local()).count();
10101        let remote_models = total_models.saturating_sub(local_models);
10102
10103        let defaults = vec![
10104            self.model_default_health(
10105                ModelCapability::Generate,
10106                self.preferred_model_for_capability(ModelCapability::Generate)
10107                    .unwrap_or(&self.config.generation_model),
10108            ),
10109            self.model_default_health(
10110                ModelCapability::Embed,
10111                self.preferred_model_for_capability(ModelCapability::Embed)
10112                    .unwrap_or(&self.config.embedding_model),
10113            ),
10114            self.model_default_health(
10115                ModelCapability::Classify,
10116                self.preferred_model_for_capability(ModelCapability::Classify)
10117                    .unwrap_or(&self.config.classification_model),
10118            ),
10119        ];
10120
10121        let mut providers = std::collections::BTreeMap::new();
10122        for schema in &schemas {
10123            let entry =
10124                providers
10125                    .entry(schema.provider.clone())
10126                    .or_insert_with(|| ProviderAccumulator {
10127                        configured: false,
10128                        local_models: 0,
10129                        remote_models: 0,
10130                        available_models: 0,
10131                        capabilities: std::collections::HashSet::new(),
10132                    });
10133
10134            entry.configured |= model_source_configured(schema);
10135            if schema.is_local() {
10136                entry.local_models += 1;
10137            } else {
10138                entry.remote_models += 1;
10139            }
10140            if schema.available_now() {
10141                entry.available_models += 1;
10142            }
10143            for capability in &schema.capabilities {
10144                entry.capabilities.insert(*capability);
10145            }
10146        }
10147
10148        let providers = providers
10149            .into_iter()
10150            .map(|(provider, acc)| ModelProviderHealth {
10151                provider,
10152                configured: acc.configured,
10153                local_models: acc.local_models,
10154                remote_models: acc.remote_models,
10155                available_models: acc.available_models,
10156                capabilities: sort_capabilities(acc.capabilities.into_iter().collect()),
10157            })
10158            .collect();
10159
10160        let capabilities = all_model_capabilities()
10161            .into_iter()
10162            .map(|capability| {
10163                let relevant: Vec<&ModelSchema> = schemas
10164                    .iter()
10165                    .filter(|schema| schema.has_capability(capability))
10166                    .collect();
10167                let available: Vec<&ModelSchema> = relevant
10168                    .iter()
10169                    .copied()
10170                    .filter(|schema| schema.available_now())
10171                    .collect();
10172                ModelCapabilityHealth {
10173                    capability,
10174                    total_models: relevant.len(),
10175                    available_models: available.len(),
10176                    local_available_models: available
10177                        .iter()
10178                        .filter(|schema| schema.is_local())
10179                        .count(),
10180                    remote_available_models: available
10181                        .iter()
10182                        .filter(|schema| !schema.is_local())
10183                        .count(),
10184                }
10185            })
10186            .collect();
10187
10188        let routing = self.routing_scenarios().await;
10189        let routing_config = self.adaptive_router.config().clone();
10190        let benchmark_priors =
10191            load_benchmark_prior_health(&self.config.state_models_dir(), &schemas);
10192
10193        ModelHealthReport {
10194            total_models,
10195            available_models,
10196            local_models,
10197            remote_models,
10198            defaults,
10199            providers,
10200            capabilities,
10201            routing_prefer_local: routing_config.prefer_local,
10202            routing_quality_first_cold_start: routing_config.quality_first_cold_start,
10203            routing_min_observations: routing_config.min_observations,
10204            routing_bootstrap_min_task_observations: routing_config.bootstrap_min_task_observations,
10205            routing_bootstrap_quality_floor: routing_config.bootstrap_quality_floor,
10206            routing_quality_weight: routing_config.quality_weight,
10207            routing_latency_weight: routing_config.latency_weight,
10208            routing_cost_weight: routing_config.cost_weight,
10209            routing_scenarios: routing,
10210            benchmark_priors,
10211            speech: self.speech_health(),
10212        }
10213    }
10214
10215    async fn routing_scenarios(&self) -> Vec<RoutingScenarioHealth> {
10216        let tracker = self.outcome_tracker.read().await;
10217        let config = self.adaptive_router.config().clone();
10218        let scenarios = [
10219            (
10220                "interactive_text",
10221                "Summarize the benefits of local-first AI routing in two sentences.",
10222                "text",
10223                RoutingWorkload::Interactive,
10224                false,
10225                false,
10226            ),
10227            (
10228                "background_code",
10229                "Write a Python function named fibonacci(n) that returns the nth Fibonacci number.",
10230                "code",
10231                RoutingWorkload::Background,
10232                false,
10233                false,
10234            ),
10235            (
10236                "interactive_tool_use",
10237                "Use the provided weather tool to get the weather for Boston.",
10238                "tool_use",
10239                RoutingWorkload::Interactive,
10240                true,
10241                false,
10242            ),
10243            (
10244                "interactive_vision",
10245                "What is in this image? Answer in one word.",
10246                "vision",
10247                RoutingWorkload::Interactive,
10248                false,
10249                true,
10250            ),
10251        ];
10252
10253        // Preview against the same live snapshot real routing uses, not the
10254        // construction-time registry. `self.unified_registry` is frozen at
10255        // engine construction — on the daemon's long-lived shared engine that
10256        // means a key connected or a model pulled since boot is invisible here,
10257        // so this health surface would claim a routing decision that differs
10258        // from what a real request now takes (the #651 staleness class).
10259        let routing_registry = self.catalog_registry_snapshot();
10260
10261        scenarios
10262            .into_iter()
10263            .map(
10264                |(name, prompt, task_family, workload, has_tools, has_vision)| {
10265                    let decision = self.adaptive_router.route_context_aware(
10266                        prompt,
10267                        0,
10268                        &routing_registry,
10269                        &tracker,
10270                        has_tools,
10271                        has_vision,
10272                        workload,
10273                    );
10274                    let quality_first_cold_start = if has_tools || has_vision {
10275                        config.quality_first_cold_start
10276                    } else if task_family == "code"
10277                        && matches!(workload, RoutingWorkload::Background)
10278                    {
10279                        false
10280                    } else {
10281                        config.quality_first_cold_start
10282                    };
10283                    RoutingScenarioHealth {
10284                        name: name.to_string(),
10285                        task_family: task_family.to_string(),
10286                        workload,
10287                        has_tools,
10288                        has_vision,
10289                        prefer_local: if task_family == "speech" {
10290                            self.speech_policy.prefer_local
10291                        } else {
10292                            config.prefer_local
10293                        },
10294                        quality_first_cold_start,
10295                        bootstrap_min_task_observations: config.bootstrap_min_task_observations,
10296                        bootstrap_quality_floor: config.bootstrap_quality_floor,
10297                        model_id: decision.model_id,
10298                        model_name: decision.model_name,
10299                        reason: decision.reason,
10300                        strategy: decision.strategy,
10301                    }
10302                },
10303            )
10304            .collect()
10305    }
10306
10307    /// Run a real speech smoke test through the configured local and/or remote paths.
10308    pub async fn smoke_test_speech(
10309        &self,
10310        local: bool,
10311        remote: bool,
10312    ) -> Result<SpeechSmokeReport, InferenceError> {
10313        let mut report = SpeechSmokeReport::default();
10314
10315        if local {
10316            let tts = self
10317                .preferred_speech_schema(ModelCapability::TextToSpeech, true, false)
10318                .ok_or_else(|| {
10319                    InferenceError::InferenceFailed(
10320                        "no local text-to-speech model available".into(),
10321                    )
10322                })?;
10323            let stt = self
10324                .preferred_speech_schema(ModelCapability::SpeechToText, true, false)
10325                .ok_or_else(|| {
10326                    InferenceError::InferenceFailed(
10327                        "no local speech-to-text model available".into(),
10328                    )
10329                })?;
10330            report.local = Some(
10331                self.run_speech_smoke_path("local", &tts, &stt, "Testing CAR local speech path.")
10332                    .await?,
10333            );
10334        } else {
10335            report.skipped.push("local".to_string());
10336        }
10337
10338        if remote {
10339            let tts = self
10340                .preferred_speech_schema(ModelCapability::TextToSpeech, false, true)
10341                .ok_or_else(|| {
10342                    InferenceError::InferenceFailed(
10343                        "no remote text-to-speech model available".into(),
10344                    )
10345                })?;
10346            let stt = self
10347                .preferred_speech_schema(ModelCapability::SpeechToText, false, true)
10348                .ok_or_else(|| {
10349                    InferenceError::InferenceFailed(
10350                        "no remote speech-to-text model available".into(),
10351                    )
10352                })?;
10353            report.remote = Some(
10354                self.run_speech_smoke_path("remote", &tts, &stt, "Testing CAR remote speech path.")
10355                    .await?,
10356            );
10357        } else {
10358            report.skipped.push("remote".to_string());
10359        }
10360
10361        Ok(report)
10362    }
10363
10364    fn speech_candidates(
10365        &self,
10366        capability: ModelCapability,
10367        explicit: Option<&str>,
10368    ) -> Result<Vec<ModelSchema>, InferenceError> {
10369        if let Some(model) = explicit {
10370            let schema = self
10371                .unified_registry
10372                .get(model)
10373                .or_else(|| self.unified_registry.find_by_name(model))
10374                .cloned()
10375                .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?;
10376            if !schema.has_capability(capability) {
10377                return Err(InferenceError::InferenceFailed(format!(
10378                    "model {} does not support {:?}",
10379                    schema.name, capability
10380                )));
10381            }
10382            return Ok(vec![schema]);
10383        }
10384
10385        let mut candidates: Vec<ModelSchema> = self
10386            .unified_registry
10387            .query(&ModelFilter {
10388                capabilities: vec![capability],
10389                ..Default::default()
10390            })
10391            .into_iter()
10392            .cloned()
10393            .collect();
10394
10395        if candidates.is_empty() {
10396            return Err(InferenceError::InferenceFailed(format!(
10397                "no models registered for capability {:?}",
10398                capability
10399            )));
10400        }
10401
10402        candidates.sort_by_key(|model| self.speech_sort_key(capability, model));
10403        if !self.speech_policy.allow_remote_fallback
10404            && candidates.iter().any(|model| model.is_local())
10405        {
10406            candidates.retain(|model| model.is_local());
10407        }
10408
10409        Ok(candidates)
10410    }
10411
10412    /// Resolve a car-canonical model id (e.g. `mlx/flux-1-lite-8b:q4`) to the
10413    /// HuggingFace repo (`mlx-community/Flux-1.lite-8B-MLX-Q4`) that the
10414    /// external Python CLIs expect. Falls back to the input if no schema
10415    /// matches or the schema is not MLX-sourced.
10416    #[allow(dead_code)] // conditionally compiled — used only on external-agent HF resolution paths
10417    fn resolve_external_hf_repo(
10418        &self,
10419        explicit: Option<&str>,
10420        capability: ModelCapability,
10421    ) -> Option<String> {
10422        let id = explicit?;
10423        let schema = self
10424            .unified_registry
10425            .get(id)
10426            .or_else(|| self.unified_registry.find_by_name(id))?;
10427        if !schema.has_capability(capability) {
10428            return Some(id.to_string());
10429        }
10430        if let ModelSource::Mlx { hf_repo, .. } = &schema.source {
10431            return Some(hf_repo.clone());
10432        }
10433        Some(id.to_string())
10434    }
10435
10436    fn media_generation_candidates(
10437        &self,
10438        capability: ModelCapability,
10439        explicit: Option<&str>,
10440    ) -> Result<Vec<ModelSchema>, InferenceError> {
10441        if let Some(model) = explicit {
10442            let schema = self
10443                .unified_registry
10444                .get(model)
10445                .or_else(|| self.unified_registry.find_by_name(model))
10446                .cloned()
10447                .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?;
10448            if !schema.has_capability(capability) {
10449                return Err(InferenceError::InferenceFailed(format!(
10450                    "model {} does not support {:?}",
10451                    schema.name, capability
10452                )));
10453            }
10454            return Ok(vec![schema]);
10455        }
10456
10457        let mut candidates: Vec<ModelSchema> = self
10458            .unified_registry
10459            .query(&ModelFilter {
10460                capabilities: vec![capability],
10461                local_only: true,
10462                ..Default::default()
10463            })
10464            .into_iter()
10465            .cloned()
10466            .collect();
10467        candidates.sort_by_key(|schema| (!schema.available, schema.size_mb()));
10468        if candidates.is_empty() {
10469            return Err(InferenceError::InferenceFailed(format!(
10470                "no models registered for capability {:?}",
10471                capability
10472            )));
10473        }
10474        Ok(candidates)
10475    }
10476
10477    fn preferred_speech_schema(
10478        &self,
10479        capability: ModelCapability,
10480        local_only: bool,
10481        remote_only: bool,
10482    ) -> Option<ModelSchema> {
10483        let available_only = remote_only;
10484        let mut candidates: Vec<ModelSchema> = self
10485            .unified_registry
10486            .query(&ModelFilter {
10487                capabilities: vec![capability],
10488                available_only,
10489                ..Default::default()
10490            })
10491            .into_iter()
10492            .filter(|schema| {
10493                (!local_only || schema.is_local()) && (!remote_only || schema.is_remote())
10494            })
10495            .cloned()
10496            .collect();
10497        candidates.sort_by_key(|model| self.speech_sort_key(capability, model));
10498        candidates.into_iter().next()
10499    }
10500
10501    fn speech_health_default_name(
10502        &self,
10503        capability: ModelCapability,
10504        local_only: bool,
10505        remote_only: bool,
10506    ) -> Option<String> {
10507        let preferred = match capability {
10508            ModelCapability::SpeechToText if local_only => {
10509                self.speech_policy.preferred_local_stt.as_ref()
10510            }
10511            ModelCapability::SpeechToText if remote_only => {
10512                self.speech_policy.preferred_remote_stt.as_ref()
10513            }
10514            ModelCapability::TextToSpeech if local_only => {
10515                self.speech_policy.preferred_local_tts.as_ref()
10516            }
10517            ModelCapability::TextToSpeech if remote_only => {
10518                self.speech_policy.preferred_remote_tts.as_ref()
10519            }
10520            _ => None,
10521        };
10522
10523        preferred
10524            .filter(|name| {
10525                self.unified_registry.list().iter().any(|schema| {
10526                    schema.name == **name
10527                        && schema.has_capability(capability)
10528                        && (!local_only || schema.is_local())
10529                        && (!remote_only || schema.is_remote())
10530                })
10531            })
10532            .cloned()
10533            .or_else(|| {
10534                self.preferred_speech_schema(capability, local_only, remote_only)
10535                    .map(|schema| schema.name)
10536            })
10537    }
10538
10539    fn model_default_health(
10540        &self,
10541        capability: ModelCapability,
10542        configured_model: &str,
10543    ) -> ModelDefaultHealth {
10544        let schema = self
10545            .unified_registry
10546            .find_by_name(configured_model)
10547            .or_else(|| self.unified_registry.get(configured_model));
10548
10549        ModelDefaultHealth {
10550            capability,
10551            configured_model: configured_model.to_string(),
10552            available: schema.is_some_and(ModelSchema::available_now),
10553            is_local: schema.is_some_and(ModelSchema::is_local),
10554            provider: schema.map(|model| model.provider.clone()),
10555        }
10556    }
10557
10558    fn speech_sort_key(
10559        &self,
10560        capability: ModelCapability,
10561        model: &ModelSchema,
10562    ) -> (u8, u8, u8, u8, u64, u64) {
10563        let policy_preference = match capability {
10564            ModelCapability::SpeechToText if model.is_local() => {
10565                self.speech_policy.preferred_local_stt.as_ref()
10566            }
10567            ModelCapability::SpeechToText => self.speech_policy.preferred_remote_stt.as_ref(),
10568            ModelCapability::TextToSpeech if model.is_local() => {
10569                self.speech_policy.preferred_local_tts.as_ref()
10570            }
10571            ModelCapability::TextToSpeech => self.speech_policy.preferred_remote_tts.as_ref(),
10572            _ => None,
10573        };
10574        let local_rank = if self.speech_policy.prefer_local {
10575            if model.is_local() {
10576                0
10577            } else {
10578                1
10579            }
10580        } else if model.is_remote() {
10581            0
10582        } else {
10583            1
10584        };
10585        let availability_rank = if model.available {
10586            0
10587        } else if model.is_local() {
10588            1
10589        } else {
10590            2
10591        };
10592        let policy_rank: u8 = if policy_preference.is_some_and(|preferred| preferred == &model.name)
10593        {
10594            0
10595        } else {
10596            1
10597        };
10598        let speech_rank = match capability {
10599            // Kokoro first, deliberately. `Qwen3-TTS-12Hz-1.7B-Base-5bit` used
10600            // to rank 0 here, but CAR has **no Qwen3-TTS backend** — the only
10601            // local MLX TTS loaders are `backend::mlx_kokoro` and
10602            // `backend::mlx_parakeet`, and the TTS path calls
10603            // `KokoroBackend::load` unconditionally. Preferring Qwen3-TTS
10604            // therefore fed Qwen3 weights to Kokoro's architecture and every
10605            // synthesis died on `missing tensor:
10606            // bert.embeddings.word_embeddings.weight`, so local TTS never
10607            // worked at all (Parslee-ai/car#640).
10608            //
10609            // Ranking follows what can actually be loaded. Restore Qwen3-TTS to
10610            // the front when a backend for it exists — its advanced controls
10611            // (voice cloning, `voice_instruction`) are already modelled in
10612            // `SynthesizeRequest` and are worth preferring once loadable.
10613            ModelCapability::TextToSpeech => {
10614                if model.name == "Kokoro-82M-bf16" {
10615                    0
10616                } else if model.name == "Kokoro-82M-6bit" {
10617                    1
10618                } else if model.name == "Qwen3-TTS-12Hz-1.7B-Base-5bit" {
10619                    // Last among curated TTS: cataloged and downloadable, but
10620                    // not loadable until it has a backend.
10621                    3
10622                } else {
10623                    2
10624                }
10625            }
10626            ModelCapability::SpeechToText => {
10627                if model.name == "Parakeet-TDT-0.6B-v3-MLX" {
10628                    0
10629                } else {
10630                    1
10631                }
10632            }
10633            _ => 0,
10634        };
10635        let latency_rank = model.performance.latency_p50_ms.unwrap_or(u64::MAX);
10636        let size_rank = model.cost.size_mb.unwrap_or(u64::MAX);
10637        (
10638            local_rank,
10639            availability_rank,
10640            policy_rank,
10641            speech_rank,
10642            latency_rank,
10643            size_rank,
10644        )
10645    }
10646
10647    async fn run_speech_smoke_path(
10648        &self,
10649        path: &str,
10650        tts: &ModelSchema,
10651        stt: &ModelSchema,
10652        text: &str,
10653    ) -> Result<SpeechSmokePathReport, InferenceError> {
10654        let work_dir = temp_work_dir(&format!("speech-smoke-{path}"))?;
10655        let audio_path = work_dir.join(format!("{path}.wav"));
10656        let synth = self
10657            .synthesize(SynthesizeRequest {
10658                text: text.to_string(),
10659                model: Some(tts.name.clone()),
10660                voice: default_speech_voice(tts),
10661                language: Some("en".to_string()),
10662                output_path: Some(audio_path.display().to_string()),
10663                ..SynthesizeRequest::default()
10664            })
10665            .await?;
10666        let transcript = self
10667            .transcribe(TranscribeRequest {
10668                audio_path: synth.audio_path.clone(),
10669                model: Some(stt.name.clone()),
10670                language: Some("en".to_string()),
10671                prompt: None,
10672                timestamps: false,
10673            })
10674            .await?;
10675
10676        Ok(SpeechSmokePathReport {
10677            path: path.to_string(),
10678            tts_model: synth.model_used.unwrap_or_else(|| tts.name.clone()),
10679            stt_model: transcript.model_used.unwrap_or_else(|| stt.name.clone()),
10680            audio_path: PathBuf::from(synth.audio_path),
10681            transcript: transcript.text,
10682        })
10683    }
10684
10685    async fn ensure_speech_runtime(&self) -> Result<SpeechRuntime, InferenceError> {
10686        let mut guard = self.speech_runtime.lock().await;
10687        if let Some(runtime) = guard.as_ref() {
10688            if runtime.is_ready() {
10689                return Ok(runtime.clone());
10690            }
10691        }
10692
10693        let runtime =
10694            SpeechRuntime::new(speech_runtime_root_from_models_dir(&self.config.models_dir));
10695        if !runtime.is_ready() {
10696            bootstrap_speech_runtime(&runtime).await?;
10697        }
10698        if !runtime.is_ready() {
10699            return Err(InferenceError::InferenceFailed(format!(
10700                "managed speech runtime is not ready at {}",
10701                runtime.root.display()
10702            )));
10703        }
10704
10705        *guard = Some(runtime.clone());
10706        Ok(runtime)
10707    }
10708
10709    /// Transcribe an audio file with the in-process whisper.cpp backend — the
10710    /// cross-platform on-device STT (`ModelSource::WhisperCpp`) that the `car
10711    /// speech` catalog offers where MLX isn't available. The ggml model
10712    /// lazy-downloads on first use via `car-whisper`. Runs on a blocking pool
10713    /// (whisper.cpp is synchronous + CPU/GPU-bound).
10714    ///
10715    /// NB: loads the model per call for now — a resident-context cache is a
10716    /// follow-up; the catalog STT path is setup/smoke/occasional, not hot.
10717    async fn transcribe_whisper(
10718        &self,
10719        schema: &ModelSchema,
10720        model: &str,
10721        req: &TranscribeRequest,
10722        reservation: Option<&mut resource_policy::LocalLoadReservation>,
10723    ) -> Result<TranscribeResult, InferenceError> {
10724        let model = model.to_string();
10725        let model_path = car_whisper::ensure_model(&model)
10726            .map_err(|e| InferenceError::InferenceFailed(format!("whisper download: {e}")))?;
10727        let measured_bytes = backend_cache::estimate_model_size(&model_path);
10728        let reservation = reservation.ok_or_else(|| {
10729            InferenceError::InferenceFailed("local Whisper path missing admission".into())
10730        })?;
10731        reservation
10732            .reconcile_measured_weights(measured_bytes)
10733            .map_err(InferenceError::from)?;
10734        let detached_lease = reservation.detached_lease();
10735        // whisper.cpp accepts "auto" for language auto-detection.
10736        let language = req.language.clone().unwrap_or_else(|| "auto".to_string());
10737        let audio_path = std::path::PathBuf::from(&req.audio_path);
10738        let name = schema.name.clone();
10739        let req_language = req.language.clone();
10740        let text =
10741            run_admitted_blocking(detached_lease, move || -> Result<String, InferenceError> {
10742                // LOCAL_ADMISSION_BOUNDARY:speech-stt-dispatch
10743                let stt = car_whisper::WhisperStt::load_from_path(&model_path, &language)
10744                    .map_err(|e| InferenceError::InferenceFailed(format!("whisper load: {e}")))?;
10745                stt.transcribe_file(&audio_path).map_err(|e| {
10746                    InferenceError::InferenceFailed(format!("whisper transcribe: {e}"))
10747                })
10748            })
10749            .await
10750            .map_err(|e| InferenceError::InferenceFailed(format!("whisper join: {e}")))??;
10751        Ok(TranscribeResult::text_only(text, Some(name), req_language))
10752    }
10753
10754    async fn transcribe_local_mlx(
10755        &self,
10756        schema: &ModelSchema,
10757        req: &TranscribeRequest,
10758        reservation: Option<&mut resource_policy::LocalLoadReservation>,
10759    ) -> Result<TranscribeResult, InferenceError> {
10760        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
10761        let reservation = reservation.ok_or_else(|| {
10762            InferenceError::InferenceFailed("local STT path missing admission".into())
10763        })?;
10764        reservation
10765            .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
10766            .map_err(InferenceError::from)?;
10767        // Native MLX transcription via Parakeet backend (no Python shelling).
10768        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
10769        {
10770            // Same story as TTS: the native backend can't load every catalogued
10771            // STT checkpoint, so hand off to the managed mlx-audio runtime
10772            // instead of failing outright (Parslee-ai/car#640).
10773            // LOCAL_ADMISSION_BOUNDARY:speech-stt-dispatch
10774            let parakeet = match backend::mlx_parakeet::ParakeetBackend::load(&model_dir) {
10775                Ok(p) => p,
10776                Err(native_err) => {
10777                    tracing::info!(
10778                        model = %schema.name,
10779                        error = %native_err,
10780                        "native MLX STT backend can't load this model; \
10781                         falling back to the managed mlx-audio runtime"
10782                    );
10783                    return self
10784                        .transcribe_via_speech_runtime(schema, req, reservation.detached_lease())
10785                        .await
10786                        .map_err(|runtime_err| {
10787                            InferenceError::InferenceFailed(format!(
10788                                "native MLX backend failed ({native_err}); \
10789                                 mlx-audio runtime fallback also failed ({runtime_err}). \
10790                                 Install the runtime with `car speech install`."
10791                            ))
10792                        });
10793                }
10794            };
10795            // Only pay the word-grouping cost when the caller asked.
10796            let (text, words) = if req.timestamps {
10797                parakeet
10798                    .transcribe_detailed(Path::new(&req.audio_path))
10799                    .map_err(|e| InferenceError::InferenceFailed(format!("native STT: {e}")))?
10800            } else {
10801                let t = parakeet
10802                    .transcribe(Path::new(&req.audio_path))
10803                    .map_err(|e| InferenceError::InferenceFailed(format!("native STT: {e}")))?;
10804                (t, Vec::new())
10805            };
10806            Ok(TranscribeResult {
10807                text,
10808                model_used: Some(schema.name.clone()),
10809                language: req.language.clone(),
10810                words,
10811                routing_explanation: None,
10812            })
10813        }
10814
10815        // Non-Apple-Silicon: the Python speech runtime is the only path.
10816        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
10817        {
10818            self.transcribe_via_speech_runtime(schema, req, reservation.detached_lease())
10819                .await
10820        }
10821    }
10822
10823    /// Transcribe through the managed `mlx-audio` Python runtime.
10824    ///
10825    /// Twin of [`Self::synthesize_via_speech_runtime`], and available on every
10826    /// platform for the same reason: the native MLX Parakeet backend cannot load
10827    /// the shipped checkpoint (`missing tensor: encoder.layers.0.ff1.norm.weight`),
10828    /// so without this local STT is dead on Apple Silicon (Parslee-ai/car#640).
10829    async fn transcribe_via_speech_runtime(
10830        &self,
10831        schema: &ModelSchema,
10832        req: &TranscribeRequest,
10833        detached_lease: resource_policy::DetachedLocalLease,
10834    ) -> Result<TranscribeResult, InferenceError> {
10835        {
10836            let runtime = self.ensure_speech_runtime().await?;
10837            let hf_repo = match &schema.source {
10838                ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
10839                _ => {
10840                    return Err(InferenceError::InferenceFailed(format!(
10841                        "speech runtime needs an MLX model repo; {} is not one",
10842                        schema.id
10843                    )))
10844                }
10845            };
10846            let output_dir = temp_work_dir("stt")?;
10847            let output_prefix = output_dir.join("transcript");
10848            let mut args = vec![
10849                "--model".to_string(),
10850                hf_repo,
10851                "--audio".to_string(),
10852                req.audio_path.clone(),
10853                "--output-path".to_string(),
10854                output_prefix.display().to_string(),
10855                "--format".to_string(),
10856                "json".to_string(),
10857            ];
10858            if let Some(language) = &req.language {
10859                args.push("--language".to_string());
10860                args.push(normalize_lang_code(language));
10861            }
10862            if let Some(prompt) = &req.prompt {
10863                args.push("--context".to_string());
10864                args.push(prompt.clone());
10865            }
10866            if req.timestamps {
10867                args.push("--verbose".to_string());
10868            }
10869
10870            let output =
10871                run_mlx_audio_command(&runtime, "stt.generate", &args, detached_lease).await?;
10872            let text = read_transcription_result(&output_prefix)?
10873                .or_else(|| extract_text_from_payload(&output.stdout))
10874                .ok_or_else(|| {
10875                    InferenceError::InferenceFailed(format!(
10876                        "mlx-audio transcription returned no text: {}",
10877                        output.stderr
10878                    ))
10879                })?;
10880
10881            Ok(TranscribeResult {
10882                text,
10883                model_used: Some(schema.name.clone()),
10884                language: req.language.clone(),
10885                words: Vec::new(),
10886                routing_explanation: None,
10887            })
10888        }
10889    }
10890
10891    async fn synthesize_local_mlx(
10892        &self,
10893        schema: &ModelSchema,
10894        req: &SynthesizeRequest,
10895        reservation: Option<&mut resource_policy::LocalLoadReservation>,
10896    ) -> Result<SynthesizeResult, InferenceError> {
10897        // Single entry-point check for Qwen3-TTS advanced controls.
10898        // Hoisted here so that a Kokoro → Kokoro-bf16 fallback chain
10899        // doesn't double-warn, and so strict callers get one clean
10900        // error instead of being lied to by partial success.
10901        let requested = req.requested_advanced_controls();
10902        let repo_supports_advanced = match &schema.source {
10903            ModelSource::Mlx { hf_repo, .. } => hf_repo.to_ascii_lowercase().contains("qwen3-tts"),
10904            _ => false,
10905        };
10906        if !requested.is_empty() && !repo_supports_advanced {
10907            if req.strict_capabilities {
10908                return Err(InferenceError::InferenceFailed(format!(
10909                    "model {name} does not support Qwen3-TTS advanced controls {requested:?}; \
10910                     route to a Qwen3-TTS model or set strict_capabilities = false to degrade",
10911                    name = schema.name,
10912                )));
10913            }
10914            tracing::warn!(
10915                model = %schema.name,
10916                fields = ?requested,
10917                "Qwen3-TTS advanced controls set on non-Qwen3-TTS backend — ignored \
10918                 (set strict_capabilities=true to error instead)"
10919            );
10920        }
10921
10922        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
10923        let reservation = reservation.ok_or_else(|| {
10924            InferenceError::InferenceFailed("local TTS path missing admission".into())
10925        })?;
10926        reservation
10927            .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
10928            .map_err(InferenceError::from)?;
10929
10930        // Native MLX synthesis via Kokoro backend (no Python shelling).
10931        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
10932        {
10933            // The native Apple-Silicon path is Kokoro-only today; a
10934            // Qwen3-TTS schema would have routed here but the backend
10935            // has no cloning support yet. Strict callers are already
10936            // stopped above; degrade-ok callers get a second, narrower
10937            // note about the native-vs-Python capability gap.
10938            if repo_supports_advanced && !requested.is_empty() {
10939                if req.strict_capabilities {
10940                    return Err(InferenceError::InferenceFailed(format!(
10941                        "native MLX TTS backend does not yet implement Qwen3-TTS advanced \
10942                         controls {requested:?}; run on non-Apple-Silicon to use the Python \
10943                         mlx-audio fallback, or set strict_capabilities = false"
10944                    )));
10945                }
10946                tracing::warn!(
10947                    model = %schema.name,
10948                    fields = ?requested,
10949                    "Qwen3-TTS advanced controls are not yet implemented in the native MLX TTS \
10950                     backend; synthesizing without cloning/voice-design"
10951                );
10952            }
10953            let size = backend_cache::estimate_model_size(&model_dir);
10954            let cache_key = reservation.model_id().to_string();
10955            // LOCAL_ADMISSION_BOUNDARY:speech-tts-dispatch
10956            let (handle, retention) = match Self::load_backend_healing(
10957                &cache_key,
10958                model_dir,
10959                &self.kokoro_cache,
10960                size,
10961                reservation,
10962                backend::mlx_kokoro::KokoroBackend::load,
10963                || self.unified_registry.redownload_local(&schema.id),
10964            )
10965            .await
10966            {
10967                Ok(handle) => handle,
10968                // The native MLX backend can't serve every catalogued TTS model
10969                // — it implements a plain iSTFTNet vocoder, while Kokoro's
10970                // shipped checkpoint is StyleTTS2 and Qwen3-TTS has no backend
10971                // at all (Parslee-ai/car#640). Rather than fail outright, hand
10972                // off to the managed `mlx-audio` runtime, which is upstream's
10973                // own implementation and loads all of them. Mirrors the
10974                // native/external split `generate_image` uses for Flux.
10975                Err(native_err) => {
10976                    tracing::info!(
10977                        model = %schema.name,
10978                        error = %native_err,
10979                        "native MLX TTS backend can't load this model; \
10980                         falling back to the managed mlx-audio runtime"
10981                    );
10982                    return self
10983                        .synthesize_via_speech_runtime(schema, req, reservation.detached_lease())
10984                        .await
10985                        .map_err(|runtime_err| {
10986                            InferenceError::InferenceFailed(format!(
10987                                "native MLX backend failed ({native_err}); \
10988                                 mlx-audio runtime fallback also failed ({runtime_err}). \
10989                                 Install the runtime with `car speech install`."
10990                            ))
10991                        });
10992                }
10993            };
10994
10995            let output_path = req.output_path.clone().unwrap_or_else(|| {
10996                let dir = std::env::temp_dir().join("car_tts");
10997                let _ = std::fs::create_dir_all(&dir);
10998                dir.join("output.wav").display().to_string()
10999            });
11000            let voice = req.voice.as_deref().unwrap_or("af_heart").to_string();
11001            let text = req.text.clone();
11002            let detached_lease = (retention == backend_cache::BackendRetention::Transient)
11003                .then(|| reservation.detached_lease());
11004            // Serialize on the shared Metal device (see `mlx_device_lock`): a
11005            // kokoro eval concurrent with a flux/ltx eval races the command
11006            // encoder and segfaults the process. The per-model `handle.lock()`
11007            // alone does not prevent a cross-model device race. Held inside the
11008            // blocking closure so it survives request-deadline abandonment.
11009            let device_guard = Self::mlx_device_lock().lock_owned().await;
11010            let op = tokio::task::spawn_blocking(move || -> Result<PathBuf, InferenceError> {
11011                let _detached_lease = detached_lease;
11012                let _device_guard = device_guard;
11013                let mut guard = handle.lock().map_err(|_| {
11014                    InferenceError::InferenceFailed("kokoro backend mutex poisoned".into())
11015                })?;
11016                guard
11017                    .synthesize(&text, Some(&voice), Path::new(&output_path))
11018                    .map_err(|e| InferenceError::InferenceFailed(format!("native TTS: {e}")))
11019            })
11020            .await
11021            .map_err(|e| InferenceError::InferenceFailed(format!("kokoro task join: {e}")))??;
11022
11023            let final_path =
11024                materialize_audio_output(&op, req.output_path.as_deref(), &req.format)?;
11025            Ok(SynthesizeResult {
11026                audio_path: final_path.display().to_string(),
11027                media_type: media_type_for_format(&req.format),
11028                model_used: Some(schema.name.clone()),
11029                voice_used: req.voice.clone(),
11030                routing_explanation: None,
11031            })
11032        }
11033
11034        // Non-Apple-Silicon: the Python speech runtime is the only path.
11035        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
11036        {
11037            self.synthesize_via_speech_runtime(schema, req, reservation.detached_lease())
11038                .await
11039        }
11040    }
11041
11042    /// Synthesize through the managed `mlx-audio` Python runtime.
11043    ///
11044    /// This is upstream's own Kokoro implementation, so it is the reference for
11045    /// what local TTS should sound like. It was the only path on non-Apple
11046    /// platforms and was compiled out on Apple Silicon, which assumed the native
11047    /// MLX backend worked; that backend implements a plain iSTFTNet vocoder while
11048    /// Kokoro is StyleTTS2 (AdaIN conditioning, Snake activations, a
11049    /// harmonic-plus-noise source), so it can't load the shipped checkpoint and
11050    /// local TTS was dead on macOS (Parslee-ai/car#640). Now available on every
11051    /// platform, as the fallback when the native backend can't serve a model —
11052    /// the same native/external split `generate_image` already uses for Flux
11053    /// vs. mflux.
11054    async fn synthesize_via_speech_runtime(
11055        &self,
11056        schema: &ModelSchema,
11057        req: &SynthesizeRequest,
11058        detached_lease: resource_policy::DetachedLocalLease,
11059    ) -> Result<SynthesizeResult, InferenceError> {
11060        let runtime = self.ensure_speech_runtime().await?;
11061        let primary_hf_repo = match &schema.source {
11062            ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
11063            _ => {
11064                return Err(InferenceError::InferenceFailed(format!(
11065                    "speech runtime needs an MLX model repo; {} is not one",
11066                    schema.id
11067                )))
11068            }
11069        };
11070        let (produced, model_used) = match self
11071            .synthesize_local_mlx_repo(
11072                &runtime,
11073                &primary_hf_repo,
11074                schema.name.as_str(),
11075                req,
11076                detached_lease.clone(),
11077            )
11078            .await
11079        {
11080            Ok(result) => result,
11081            Err(primary_err)
11082                if primary_hf_repo == "mlx-community/Kokoro-82M-6bit"
11083                    && kokoro_runtime_fallback_enabled() =>
11084            {
11085                let fallback_repo = "mlx-community/Kokoro-82M-bf16";
11086                let fallback_name = "Kokoro-82M-bf16";
11087                match self
11088                    .synthesize_local_mlx_repo(
11089                        &runtime,
11090                        fallback_repo,
11091                        fallback_name,
11092                        req,
11093                        detached_lease.clone(),
11094                    )
11095                    .await
11096                {
11097                    Ok(result) => result,
11098                    Err(fallback_err) => {
11099                        return Err(InferenceError::InferenceFailed(format!(
11100                            "{primary_err}; fallback {fallback_name} also failed: {fallback_err}"
11101                        )));
11102                    }
11103                }
11104            }
11105            Err(err) => return Err(err),
11106        };
11107        let final_path =
11108            materialize_audio_output(&produced, req.output_path.as_deref(), &req.format)?;
11109
11110        Ok(SynthesizeResult {
11111            audio_path: final_path.display().to_string(),
11112            media_type: media_type_for_format(&req.format),
11113            model_used: Some(model_used),
11114            voice_used: req.voice.clone(),
11115            routing_explanation: None,
11116        })
11117    }
11118
11119    async fn synthesize_local_mlx_repo(
11120        &self,
11121        runtime: &SpeechRuntime,
11122        hf_repo: &str,
11123        model_name: &str,
11124        req: &SynthesizeRequest,
11125        detached_lease: resource_policy::DetachedLocalLease,
11126    ) -> Result<(PathBuf, String), InferenceError> {
11127        let output_dir = temp_work_dir("tts")?;
11128        let mut args = vec![
11129            "--model".to_string(),
11130            hf_repo.to_string(),
11131            "--text".to_string(),
11132            req.text.clone(),
11133            "--output_path".to_string(),
11134            output_dir.display().to_string(),
11135        ];
11136        if let Some(voice) = &req.voice {
11137            args.push("--voice".to_string());
11138            args.push(voice.clone());
11139        }
11140        if let Some(speed) = req.speed {
11141            args.push("--speed".to_string());
11142            args.push(speed.to_string());
11143        }
11144        let repo_lower = hf_repo.to_ascii_lowercase();
11145        if repo_lower.contains("kokoro") {
11146            args.push("--lang_code".to_string());
11147            args.push(kokoro_lang_code(req.language.as_deref()).to_string());
11148        } else if let Some(language) = &req.language {
11149            args.push("--lang_code".to_string());
11150            args.push(normalize_lang_code(language));
11151        }
11152
11153        // Qwen3-TTS advanced controls — reference-audio cloning and
11154        // voice-design natural-language instruction. The
11155        // supported/unsupported decision was already made at the
11156        // `synthesize_local_mlx` entry point; here we only need to
11157        // forward the flags to the mlx-audio CLI for Qwen3-TTS repos.
11158        if repo_lower.contains("qwen3-tts") {
11159            if let Some(ref_audio) = &req.reference_audio_path {
11160                args.push("--ref_audio".to_string());
11161                args.push(ref_audio.clone());
11162            }
11163            if let Some(ref_text) = &req.reference_text {
11164                args.push("--ref_text".to_string());
11165                args.push(ref_text.clone());
11166            }
11167            if let Some(instruct) = &req.voice_instruction {
11168                args.push("--instruct".to_string());
11169                args.push(instruct.clone());
11170            }
11171        }
11172
11173        let output = if repo_lower.contains("kokoro") {
11174            let device = std::env::var("CAR_SPEECH_KOKORO_DEVICE")
11175                .or_else(|_| std::env::var("CAR_SPEECH_MLX_DEVICE"))
11176                .unwrap_or_else(|_| "cpu".to_string());
11177            let extra_env = vec![
11178                // Force MLX device (defaults to CPU to avoid Metal/NSRangeException crashes)
11179                ("MLX_DEVICE".to_string(), device),
11180                // Prevent MPS/Metal kernel crashes by enabling CPU fallback
11181                ("PYTORCH_ENABLE_MPS_FALLBACK".to_string(), "1".to_string()),
11182            ];
11183            run_mlx_audio_command_with_env(
11184                runtime,
11185                "tts.generate",
11186                &args,
11187                &extra_env,
11188                detached_lease,
11189            )
11190            .await?
11191        } else {
11192            run_mlx_audio_command(runtime, "tts.generate", &args, detached_lease).await?
11193        };
11194        let produced = find_audio_file(&output_dir)?.ok_or_else(|| {
11195            let hint = if repo_lower.contains("kokoro") {
11196                ". Kokoro models may crash on GPU — try CAR_SPEECH_KOKORO_DEVICE=cpu or use the default Qwen3-TTS model"
11197            } else {
11198                ""
11199            };
11200            InferenceError::InferenceFailed(format!(
11201                "mlx-audio synthesis produced no audio file: {}{}",
11202                output.stderr, hint
11203            ))
11204        })?;
11205        Ok((produced, model_name.to_string()))
11206    }
11207
11208    async fn transcribe_elevenlabs(
11209        &self,
11210        schema: &ModelSchema,
11211        req: &TranscribeRequest,
11212    ) -> Result<TranscribeResult, InferenceError> {
11213        let (endpoint, api_key) = elevenlabs_auth(
11214            schema,
11215            crate::tasks::transcribe::resolve_provider_credential_for_request,
11216        )?;
11217        let file_name = Path::new(&req.audio_path)
11218            .file_name()
11219            .and_then(|f| f.to_str())
11220            .unwrap_or("audio.wav")
11221            .to_string();
11222        let audio_bytes = tokio::fs::read(&req.audio_path).await?;
11223        let file_part = Part::bytes(audio_bytes).file_name(file_name);
11224        let mut form = Form::new()
11225            .text("model_id", schema.name.clone())
11226            .part("file", file_part);
11227        if let Some(language) = &req.language {
11228            form = form.text("language_code", language.clone());
11229        }
11230
11231        let resp = self
11232            .remote_backend
11233            .client
11234            .post(format!(
11235                "{}/v1/speech-to-text",
11236                endpoint.trim_end_matches('/')
11237            ))
11238            .header("xi-api-key", api_key)
11239            .multipart(form)
11240            .send()
11241            .await
11242            .map_err(|e| {
11243                self.remote_backend
11244                    .request_error("ElevenLabs STT request failed", &e)
11245            })?;
11246        let status = resp.status();
11247        let body = resp.text().await.map_err(|e| {
11248            InferenceError::InferenceFailed(format!("read ElevenLabs STT body: {e}"))
11249        })?;
11250        if !status.is_success() {
11251            return Err(InferenceError::InferenceFailed(format!(
11252                "ElevenLabs STT returned {status}: {body}"
11253            )));
11254        }
11255        let payload: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
11256            InferenceError::InferenceFailed(format!("parse ElevenLabs STT response: {e}"))
11257        })?;
11258        let text = payload
11259            .get("text")
11260            .and_then(|v| v.as_str())
11261            .map(str::to_string)
11262            .ok_or_else(|| {
11263                InferenceError::InferenceFailed("ElevenLabs STT response missing text".into())
11264            })?;
11265
11266        Ok(TranscribeResult {
11267            text,
11268            model_used: Some(schema.name.clone()),
11269            language: payload
11270                .get("language_code")
11271                .and_then(|v| v.as_str())
11272                .map(str::to_string),
11273            words: Vec::new(),
11274            routing_explanation: None,
11275        })
11276    }
11277
11278    async fn synthesize_elevenlabs(
11279        &self,
11280        schema: &ModelSchema,
11281        req: &SynthesizeRequest,
11282    ) -> Result<SynthesizeResult, InferenceError> {
11283        // ElevenLabs doesn't expose a Qwen3-TTS-style cloning or
11284        // voice-design surface on its `/v1/text-to-speech` endpoint;
11285        // honor the strict_capabilities contract here too.
11286        let requested = req.requested_advanced_controls();
11287        if !requested.is_empty() {
11288            if req.strict_capabilities {
11289                return Err(InferenceError::InferenceFailed(format!(
11290                    "ElevenLabs backend does not support Qwen3-TTS advanced controls \
11291                     {requested:?}; route to a Qwen3-TTS model or set strict_capabilities = false"
11292                )));
11293            }
11294            tracing::warn!(
11295                model = %schema.name,
11296                fields = ?requested,
11297                "Qwen3-TTS advanced controls ignored by ElevenLabs backend"
11298            );
11299        }
11300        let (endpoint, api_key) = elevenlabs_auth(
11301            schema,
11302            crate::tasks::synthesize::resolve_provider_credential_for_request,
11303        )?;
11304        let voice_id = req
11305            .voice
11306            .clone()
11307            .unwrap_or_else(|| "JBFqnCBsd6RMkjVDRZzb".to_string());
11308        let output_format = elevenlabs_output_format(&req.format);
11309        let url = format!(
11310            "{}/v1/text-to-speech/{}?output_format={}",
11311            endpoint.trim_end_matches('/'),
11312            voice_id,
11313            output_format
11314        );
11315
11316        let mut body = serde_json::json!({
11317            "text": req.text,
11318            "model_id": schema.name,
11319        });
11320        if let Some(language) = &req.language {
11321            body["language_code"] = serde_json::Value::String(language.clone());
11322        }
11323
11324        let resp = self
11325            .remote_backend
11326            .client
11327            .post(url)
11328            .header("xi-api-key", api_key)
11329            .header("Content-Type", "application/json")
11330            .json(&body)
11331            .send()
11332            .await
11333            .map_err(|e| {
11334                self.remote_backend
11335                    .request_error("ElevenLabs TTS request failed", &e)
11336            })?;
11337        let status = resp.status();
11338        let audio = resp.bytes().await.map_err(|e| {
11339            InferenceError::InferenceFailed(format!("read ElevenLabs TTS body: {e}"))
11340        })?;
11341        if !status.is_success() {
11342            let err_body = String::from_utf8_lossy(&audio);
11343            return Err(InferenceError::InferenceFailed(format!(
11344                "ElevenLabs TTS returned {status}: {err_body}"
11345            )));
11346        }
11347
11348        let final_path = requested_or_temp_output(req.output_path.as_deref(), &req.format)?;
11349        ensure_parent_dir(&final_path)?;
11350        tokio::fs::write(&final_path, &audio).await?;
11351
11352        Ok(SynthesizeResult {
11353            audio_path: final_path.display().to_string(),
11354            media_type: media_type_for_format(&req.format),
11355            model_used: Some(schema.name.clone()),
11356            voice_used: Some(voice_id),
11357            routing_explanation: None,
11358        })
11359    }
11360}
11361
11362#[derive(Default)]
11363struct ProviderAccumulator {
11364    configured: bool,
11365    local_models: usize,
11366    remote_models: usize,
11367    available_models: usize,
11368    capabilities: std::collections::HashSet<ModelCapability>,
11369}
11370
11371// ─── Python Speech Runtime (non-Apple-Silicon only) ──────────────────────────
11372// On Apple Silicon, speech uses native MLX backends (mlx_parakeet, mlx_kokoro).
11373
11374struct CommandOutput {
11375    stdout: String,
11376    stderr: String,
11377}
11378
11379#[derive(Debug, Clone)]
11380struct SpeechRuntime {
11381    root: PathBuf,
11382    python: PathBuf,
11383    stt_program: PathBuf,
11384    tts_program: PathBuf,
11385}
11386
11387impl SpeechRuntime {
11388    fn new(root: PathBuf) -> Self {
11389        let python = managed_venv::interpreter(&root);
11390        let stt_program = managed_venv::venv_program(&root, "mlx_audio.stt.generate");
11391        let tts_program = managed_venv::venv_program(&root, "mlx_audio.tts.generate");
11392        Self {
11393            root,
11394            python,
11395            stt_program,
11396            tts_program,
11397        }
11398    }
11399
11400    fn is_ready(&self) -> bool {
11401        // `interpreter_healthy` runs the interpreter rather than stat-ing it.
11402        // A venv whose Python was rotated away by a Homebrew upgrade still has
11403        // every console script sitting on disk, so a pure existence check
11404        // reports "ready" for a runtime where all of them die at their shebang.
11405        managed_venv::interpreter_healthy(&self.root)
11406            && self.stt_program.exists()
11407            && self.tts_program.exists()
11408    }
11409
11410    fn command_for(&self, subcommand: &str) -> Result<&Path, InferenceError> {
11411        match subcommand {
11412            "stt.generate" => Ok(&self.stt_program),
11413            "tts.generate" => Ok(&self.tts_program),
11414            _ => Err(InferenceError::InferenceFailed(format!(
11415                "unknown speech subcommand: {subcommand}"
11416            ))),
11417        }
11418    }
11419}
11420
11421async fn run_mlx_audio_command(
11422    runtime: &SpeechRuntime,
11423    subcommand: &str,
11424    args: &[String],
11425    detached_lease: resource_policy::DetachedLocalLease,
11426) -> Result<CommandOutput, InferenceError> {
11427    run_mlx_audio_command_with_env(runtime, subcommand, args, &[], detached_lease).await
11428}
11429
11430/// Run synchronous native model work with an owned machine-budget charge.
11431/// Tokio cannot cancel a started `spawn_blocking` closure, so the lease must
11432/// live inside the closure rather than in the awaiting request future.
11433async fn run_admitted_blocking<F, T>(
11434    detached_lease: resource_policy::DetachedLocalLease,
11435    operation: F,
11436) -> Result<T, tokio::task::JoinError>
11437where
11438    F: FnOnce() -> T + Send + 'static,
11439    T: Send + 'static,
11440{
11441    tokio::task::spawn_blocking(move || {
11442        let _detached_lease = detached_lease;
11443        operation()
11444    })
11445    .await
11446}
11447
11448struct DetachedSpeechProcess {
11449    child: Option<tokio::process::Child>,
11450    lease: Option<resource_policy::DetachedLocalLease>,
11451}
11452
11453impl Drop for DetachedSpeechProcess {
11454    fn drop(&mut self) {
11455        let Some(mut child) = self.child.take() else {
11456            return;
11457        };
11458        let lease = self.lease.take();
11459        let _ = child.start_kill();
11460        if tokio::runtime::Handle::try_current().is_ok() {
11461            tokio::spawn(async move {
11462                let _ = child.wait().await;
11463                drop(lease);
11464            });
11465        } else {
11466            // Without an executor we cannot confirm OS exit. Preserve both
11467            // ownership and charge rather than advertise unsafe headroom.
11468            std::mem::forget((child, lease));
11469        }
11470    }
11471}
11472
11473async fn run_mlx_audio_command_with_env(
11474    runtime: &SpeechRuntime,
11475    subcommand: &str,
11476    args: &[String],
11477    envs: &[(String, String)],
11478    detached_lease: resource_policy::DetachedLocalLease,
11479) -> Result<CommandOutput, InferenceError> {
11480    let program = runtime.command_for(subcommand)?;
11481    let mut command = Command::new(program);
11482    command.args(args);
11483    for (key, value) in envs {
11484        command.env(key, value);
11485    }
11486    command
11487        .stdout(std::process::Stdio::piped())
11488        .stderr(std::process::Stdio::piped())
11489        .kill_on_drop(true);
11490    let child = command
11491        .spawn()
11492        .map_err(|err| InferenceError::InferenceFailed(format!("{}: {err}", program.display())))?;
11493    let mut owned = DetachedSpeechProcess {
11494        child: Some(child),
11495        lease: Some(detached_lease),
11496    };
11497    let mut stdout = owned
11498        .child
11499        .as_mut()
11500        .expect("speech child owned")
11501        .stdout
11502        .take()
11503        .ok_or_else(|| {
11504            InferenceError::InferenceFailed(format!("{} stdout unavailable", program.display()))
11505        })?;
11506    let mut stderr = owned
11507        .child
11508        .as_mut()
11509        .expect("speech child owned")
11510        .stderr
11511        .take()
11512        .ok_or_else(|| {
11513            InferenceError::InferenceFailed(format!("{} stderr unavailable", program.display()))
11514        })?;
11515    let stdout_reader = tokio::spawn(async move {
11516        let mut bytes = Vec::new();
11517        stdout.read_to_end(&mut bytes).await.map(|_| bytes)
11518    });
11519    let stderr_reader = tokio::spawn(async move {
11520        let mut bytes = Vec::new();
11521        stderr.read_to_end(&mut bytes).await.map(|_| bytes)
11522    });
11523    let status = owned
11524        .child
11525        .as_mut()
11526        .expect("speech child owned")
11527        .wait()
11528        .await
11529        .map_err(|err| InferenceError::InferenceFailed(format!("{}: {err}", program.display())))?;
11530    owned.child.take();
11531    owned.lease.take();
11532    let stdout = stdout_reader
11533        .await
11534        .map_err(|err| InferenceError::InferenceFailed(format!("speech stdout join: {err}")))?
11535        .map_err(|err| InferenceError::InferenceFailed(format!("speech stdout: {err}")))?;
11536    let stderr = stderr_reader
11537        .await
11538        .map_err(|err| InferenceError::InferenceFailed(format!("speech stderr join: {err}")))?
11539        .map_err(|err| InferenceError::InferenceFailed(format!("speech stderr: {err}")))?;
11540
11541    if status.success() {
11542        Ok(CommandOutput {
11543            stdout: String::from_utf8_lossy(&stdout).to_string(),
11544            stderr: String::from_utf8_lossy(&stderr).to_string(),
11545        })
11546    } else {
11547        Err(InferenceError::InferenceFailed(format!(
11548            "{} exited with {}: {}",
11549            program.display(),
11550            status,
11551            String::from_utf8_lossy(&stderr)
11552        )))
11553    }
11554}
11555
11556async fn bootstrap_speech_runtime(runtime: &SpeechRuntime) -> Result<(), InferenceError> {
11557    let python = select_speech_python()?;
11558
11559    // A bare `uv venv` hard-fails against an existing directory, so this used to
11560    // abort on every machine that had already provisioned the runtime once —
11561    // including the case this bootstrap exists to handle, where the venv is
11562    // present but its interpreter was rotated away by a Homebrew upgrade.
11563    // `managed_venv` reuses a healthy venv and rebuilds a broken one.
11564    let outcome = managed_venv::ensure_venv(&runtime.root, &python)
11565        .await
11566        .map_err(|err| InferenceError::InferenceFailed(err.to_string()))?;
11567    if outcome == managed_venv::VenvOutcome::Recreated {
11568        tracing::warn!(
11569            root = %runtime.root.display(),
11570            "managed speech runtime had an unusable interpreter; rebuilt it \
11571             (installed packages were discarded and are being reinstalled)"
11572        );
11573    }
11574
11575    run_command(
11576        "uv",
11577        &[
11578            "pip".to_string(),
11579            "install".to_string(),
11580            "--python".to_string(),
11581            runtime.python.display().to_string(),
11582            speech_runtime_mlx_audio_spec(),
11583            "misaki[en]".to_string(),
11584            speech_runtime_spacy_model_spec(),
11585        ],
11586    )
11587    .await?;
11588
11589    Ok(())
11590}
11591
11592async fn run_command(program: &str, args: &[String]) -> Result<(), InferenceError> {
11593    let output = Command::new(program)
11594        .args(args)
11595        .output()
11596        .await
11597        .map_err(|err| InferenceError::InferenceFailed(format!("{program}: {err}")))?;
11598
11599    if output.status.success() {
11600        Ok(())
11601    } else {
11602        Err(InferenceError::InferenceFailed(format!(
11603            "{} exited with {}: {}",
11604            program,
11605            output.status,
11606            String::from_utf8_lossy(&output.stderr)
11607        )))
11608    }
11609}
11610
11611fn select_speech_python() -> Result<String, InferenceError> {
11612    if let Ok(path) = std::env::var("CAR_SPEECH_PYTHON") {
11613        if !path.trim().is_empty() {
11614            return Ok(path);
11615        }
11616    }
11617
11618    for candidate in ["python3.13", "python3.12", "python3.11"] {
11619        if command_in_path(candidate) {
11620            return Ok(candidate.to_string());
11621        }
11622    }
11623
11624    // Nothing supported on PATH — hand `uv` a bare version instead of a binary
11625    // name and let it provision one. `uv venv --python 3.12` downloads and
11626    // manages the interpreter itself, and uv is already a hard prerequisite of
11627    // this bootstrap, so this adds no new dependency.
11628    //
11629    // Without this, a machine whose only Python is newer than the supported
11630    // range (e.g. 3.14, which mlx-audio does not yet build against) could not
11631    // install the speech runtime at all, and local voice was simply unavailable
11632    // — even though the fix was one flag away (Parslee-ai/car#640).
11633    Ok(SPEECH_RUNTIME_FALLBACK_PYTHON.to_string())
11634}
11635
11636/// Python version `uv` provisions when no supported interpreter is on PATH.
11637///
11638/// Pinned to a version `mlx-audio` and `misaki` actually support — deliberately
11639/// not "whatever is newest", since the newest release is routinely ahead of what
11640/// the speech stack builds against, which is the situation this exists for.
11641const SPEECH_RUNTIME_FALLBACK_PYTHON: &str = "3.12";
11642
11643fn detect_speech_python() -> Option<String> {
11644    if let Ok(path) = std::env::var("CAR_SPEECH_PYTHON") {
11645        if !path.trim().is_empty() {
11646            return Some(path);
11647        }
11648    }
11649
11650    ["python3.13", "python3.12", "python3.11"]
11651        .into_iter()
11652        .find(|candidate| command_in_path(candidate))
11653        .map(str::to_string)
11654}
11655
11656fn speech_runtime_root_from_models_dir(_models_dir: &Path) -> PathBuf {
11657    if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
11658        if !path.trim().is_empty() {
11659            return PathBuf::from(path);
11660        }
11661    }
11662
11663    std::env::var_os("HOME")
11664        .or_else(|| std::env::var_os("USERPROFILE"))
11665        .map(PathBuf::from)
11666        .unwrap_or_else(|| PathBuf::from("."))
11667        .join(".car")
11668        .join("speech-runtime")
11669}
11670
11671fn command_in_path(name: &str) -> bool {
11672    std::env::var_os("PATH")
11673        .map(|paths| {
11674            std::env::split_paths(&paths).any(|dir| {
11675                let path = dir.join(name);
11676                path.exists() && path.is_file()
11677            })
11678        })
11679        .unwrap_or(false)
11680}
11681
11682fn speech_model_cached(schema: &ModelSchema) -> bool {
11683    match &schema.source {
11684        ModelSource::Mlx { hf_repo, .. } => huggingface_repo_has_snapshot(hf_repo),
11685        ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
11686        // OS-provided (WinRT) — nothing to cache; "cached" tracks availability.
11687        ModelSource::WindowsSpeech {} => cfg!(target_os = "windows"),
11688        // A remote credential is configuration, not a cached model artifact.
11689        // Credential truth is established only at explicit request time.
11690        ModelSource::Proprietary { .. } => false,
11691        _ => false,
11692    }
11693}
11694
11695fn model_source_configured(schema: &ModelSchema) -> bool {
11696    match &schema.source {
11697        ModelSource::RemoteApi {
11698            protocol: ApiProtocol::OpenRouter,
11699            ..
11700        } => crate::openrouter::credential_source().is_some(),
11701        ModelSource::RemoteApi {
11702            api_key_env,
11703            api_key_envs,
11704            ..
11705        } => {
11706            std::env::var(api_key_env).is_ok_and(|value| !value.trim().is_empty())
11707                || api_key_envs.iter().any(|env_var| {
11708                    std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11709                })
11710        }
11711        ModelSource::Proprietary { auth, .. } => match auth {
11712            ProprietaryAuth::ApiKeyEnv { env_var } => {
11713                std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11714            }
11715            ProprietaryAuth::BearerTokenEnv { env_var } => {
11716                std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11717            }
11718            ProprietaryAuth::OAuth2Pkce { .. } => matches!(
11719                car_auth::credential_authority_hint().state,
11720                car_auth::CredentialAuthorityState::Configured
11721            ),
11722        },
11723        ModelSource::VllmMlx { .. } => {
11724            std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available
11725        }
11726        ModelSource::Ollama { .. } => schema.available,
11727        ModelSource::CodexCli { .. } => crate::backend::codex_cli::is_available(),
11728        ModelSource::Mlx { .. }
11729        | ModelSource::ManagedVllmMlx { .. }
11730        | ModelSource::Local { .. }
11731        | ModelSource::WhisperCpp { .. } => true,
11732        // OS-provided; "configured" tracks platform availability (Windows-only).
11733        ModelSource::WindowsSpeech {} => schema.available,
11734        ModelSource::AppleFoundationModels { .. } => schema.available,
11735        // Delegated models route through a host-registered runner —
11736        // the runner's own auth / readiness is opaque here. Treat
11737        // them as configured; missing-runner errors surface at
11738        // dispatch time with a clear message.
11739        ModelSource::Delegated { .. } => true,
11740    }
11741}
11742
11743fn all_model_capabilities() -> [ModelCapability; 13] {
11744    [
11745        ModelCapability::Generate,
11746        ModelCapability::Embed,
11747        ModelCapability::Classify,
11748        ModelCapability::Code,
11749        ModelCapability::Reasoning,
11750        ModelCapability::Summarize,
11751        ModelCapability::ToolUse,
11752        ModelCapability::MultiToolCall,
11753        ModelCapability::Vision,
11754        ModelCapability::SpeechToText,
11755        ModelCapability::TextToSpeech,
11756        ModelCapability::ImageGeneration,
11757        ModelCapability::VideoGeneration,
11758    ]
11759}
11760
11761fn sort_capabilities(mut capabilities: Vec<ModelCapability>) -> Vec<ModelCapability> {
11762    capabilities.sort_by_key(|capability| {
11763        all_model_capabilities()
11764            .iter()
11765            .position(|candidate| candidate == capability)
11766            .unwrap_or(usize::MAX)
11767    });
11768    capabilities
11769}
11770
11771fn speech_model_source_label(schema: &ModelSchema) -> String {
11772    match &schema.source {
11773        ModelSource::Mlx { hf_repo, .. } => format!("mlx:{hf_repo}"),
11774        ModelSource::ManagedVllmMlx { hf_repo, .. } => {
11775            format!("managed-vllm-mlx:{hf_repo}")
11776        }
11777        ModelSource::WhisperCpp { model } => format!("whisper:{model}"),
11778        ModelSource::WindowsSpeech {} => "windows-speech".to_string(),
11779        ModelSource::Proprietary {
11780            provider, endpoint, ..
11781        } => format!("proprietary:{provider}:{endpoint}"),
11782        ModelSource::RemoteApi { endpoint, .. } => format!("remote:{endpoint}"),
11783        ModelSource::CodexCli { model } => format!("codex-cli:{model}"),
11784        ModelSource::Local { hf_repo, .. } => format!("local:{hf_repo}"),
11785        ModelSource::VllmMlx {
11786            endpoint,
11787            model_name,
11788        } => format!("vllm-mlx:{endpoint}:{model_name}"),
11789        ModelSource::Ollama { model_tag, host } => format!("ollama:{host}:{model_tag}"),
11790        ModelSource::AppleFoundationModels { use_case } => {
11791            format!(
11792                "apple-foundation:{}",
11793                use_case.as_deref().unwrap_or("default")
11794            )
11795        }
11796        ModelSource::Delegated { hint } => {
11797            format!("delegated:{}", hint.as_deref().unwrap_or("(none)"))
11798        }
11799    }
11800}
11801
11802/// Build the Qwen3-Reranker chat-template prompt for a single
11803/// `(query, document)` candidate.
11804///
11805/// The format matches upstream `reranker_quick_start.py`: a system
11806/// message pinning the answer space to yes/no, a user turn with
11807/// `<Instruct>/<Query>/<Document>`, and an assistant prefix with a
11808/// closed empty `<think>` block to force non-thinking classification.
11809fn rerank_prompt(instruction: &str, query: &str, document: &str) -> String {
11810    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\".";
11811    format!(
11812        "<|im_start|>system\n{SYSTEM}<|im_end|>\n\
11813         <|im_start|>user\n<Instruct>: {instruction}\n<Query>: {query}\n<Document>: {document}<|im_end|>\n\
11814         <|im_start|>assistant\n<think>\n\n</think>\n\n"
11815    )
11816}
11817
11818/// Interpret the first useful token from a Qwen3-Reranker greedy
11819/// decode as a relevance score. Scans up to the first few tokens so
11820/// a leading space, BOS artifact, or stray newline doesn't poison the
11821/// result. Returns 1.0 for "yes", 0.0 for "no", 0.5 on unexpected
11822/// output (with a warn so the mismatch is visible).
11823/// Map an exhausted-fallback-chain error to actionable recovery
11824/// guidance, or `None` when the underlying error isn't a
11825/// missing-backend/credential case.
11826///
11827/// Matches *specific* CAR error phrases, not broad words like "auth"
11828/// or "token", so a transient failure on an otherwise-configured model
11829/// — an HTTP 401/403/429 (`API returned 4xx`), a connection refused, a
11830/// timeout — passes through unchanged and isn't buried under
11831/// first-run setup advice. The four matched phrases are the ones the
11832/// engine actually emits when there is genuinely no runnable backend:
11833/// proprietary provider with no credential, a routed model that isn't
11834/// installed, an empty registry, or a delegated model with no runner.
11835/// See Parslee-ai/car#231 §7.1.
11836/// Recover the HTTP status the remote backend formats into
11837/// "API returned <status>: <body>" error strings, so transient
11838/// classification keys on the REAL status instead of substring-sniffing
11839/// a body that may quote another status or the word "timeout"
11840/// (I4 review; typed error plumbing is the follow-up).
11841fn parse_api_returned_status(err: &str) -> Option<u16> {
11842    let lower = err.to_ascii_lowercase();
11843    let idx = lower.find("api returned ")?;
11844    let digits: String = lower[idx + "api returned ".len()..]
11845        .chars()
11846        .take_while(|c| c.is_ascii_digit())
11847        .take(3)
11848        .collect();
11849    digits.parse().ok()
11850}
11851
11852/// The stable opening sentence of `no_backend_recovery_hint`'s guidance, and
11853/// the single phrase that means "nothing is runnable here; this is setup, not a
11854/// blip".
11855///
11856/// Exported because consumers outside this crate classify on it —
11857/// `car_server_core::coder::native_loop` stops an unattended agent build on it
11858/// rather than retrying — and `no_backend_recovery_hint` builds its first
11859/// sentence FROM this constant, so producer and consumer cannot drift apart.
11860///
11861/// Match it with `contains`, never `starts_with`: `apply_route_failure_context`
11862/// prepends a credential summary (`"{summary}; {hint}"`) whenever a route also
11863/// failed to produce a credential, so the marker is routinely mid-message.
11864pub const NO_BACKEND_RECOVERY_MARKER: &str = "no inference backend is available.";
11865
11866/// Map an exhausted-fallback-chain error to actionable recovery guidance, or
11867/// `None` when the underlying error isn't a missing-backend/credential case.
11868///
11869/// Public only so cross-crate consumers of [`NO_BACKEND_RECOVERY_MARKER`] can
11870/// pin their classifiers against the REAL producer output instead of a
11871/// hand-copied literal; it is not part of the supported surface.
11872#[doc(hidden)]
11873pub fn no_backend_recovery_hint(underlying: &str) -> Option<String> {
11874    let is_no_backend = underlying.contains("no credential")
11875        || underlying.contains("model not found")
11876        || underlying.contains("no models available")
11877        || underlying.contains("no inference runner");
11878    if !is_no_backend {
11879        return None;
11880    }
11881    // Sign-in leads. The out-of-the-box agent runs on Parslee inference, so
11882    // for the person who hit this without configuring anything, signing in IS
11883    // the fix; pulling local weights only helps a model they name themselves.
11884    // The opening marker is unchanged — consumers classify on it as a
11885    // substring (`NO_BACKEND_RECOVERY_MARKER`), and so does the coder loop.
11886    Some(format!(
11887        "{NO_BACKEND_RECOVERY_MARKER} To use Parslee's hosted models, run:\n    \
11888         car auth login\n\
11889         Or, for a model you name yourself with --model, install a local \
11890         tool-capable one (works on Windows/Linux/macOS):\n    \
11891         car models pull qwen/qwen3-4b:q4_k_m\n\
11892         (underlying error: {underlying})"
11893    ))
11894}
11895
11896/// Whether an error message means **the credential was rejected** — the remedy
11897/// is for a human to sign in again (`car auth login`), not for the machinery to
11898/// retry.
11899///
11900/// This is the single definition of that question in the workspace.
11901/// [`auth_expired_recovery_hint`] consumes it to decide whether to print re-auth
11902/// guidance, and `car_server_core::coder::native_loop::is_auth_failure` consumes
11903/// it so the coder loop pauses for sign-in instead of burning inference strikes
11904/// (three of which throw away the worktree) on a credential that cannot possibly
11905/// succeed on retry — Parslee-ai/car#888, where the real expired-token error
11906/// (`Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required`)
11907/// matched none of that loop's hand-rolled substrings.
11908///
11909/// It deliberately does **not** match transient failures — 5xx, timeouts, reset
11910/// connections. Those are worth retrying and must keep flowing down the normal
11911/// failure path; classifying one as "sign in again" would tell an operator to
11912/// fix something that is not broken.
11913pub fn is_auth_rejection_message(underlying: &str) -> bool {
11914    let l = underlying.to_ascii_lowercase();
11915    l.contains("org lookup failed")
11916        || l.contains("authentication required")
11917        || (l.contains("401") && l.contains("unauthorized"))
11918        || (l.contains("403") && l.contains("forbidden"))
11919        || l.contains("invalid_grant")
11920        || l.contains("token expired")
11921}
11922
11923/// Whether a rendered inference failure needs credential repair rather than an
11924/// infrastructure retry.
11925///
11926/// Route summaries and consumers such as the coder loop share
11927/// `AUTH_FAILURE_MESSAGE_MARKERS` instead of maintaining independent phrase
11928/// lists that can disagree when a summary is added or reworded.
11929pub fn is_auth_failure_message(message: &str) -> bool {
11930    let lower = message.to_ascii_lowercase();
11931    AUTH_FAILURE_MESSAGE_MARKERS
11932        .iter()
11933        .any(|marker| lower.contains(marker))
11934        || is_auth_rejection_message(message)
11935}
11936
11937/// Record `candidate` as the dead lane if `error` is a credential rejection and
11938/// no dead lane has been recorded yet.
11939///
11940/// FIRST-wins, not last: the chain is walked in preference order, so the first
11941/// rejected lane is the one the operator actually configured and the one worth
11942/// naming. Non-auth failures (5xx, timeouts) leave the slot untouched — they are
11943/// not a sign-in problem. Extracted from the fallback loop so the rule is
11944/// directly testable (Parslee-ai/car#888).
11945fn record_auth_dead_lane(slot: &mut Option<String>, candidate: &str, error: &str) {
11946    if slot.is_none() && is_auth_rejection_message(error) {
11947        *slot = Some(candidate.to_string());
11948    }
11949}
11950
11951/// Whether the candidate that served was the on-device model appended behind
11952/// an otherwise remote-only chain. Comparing the attempted ids (rather than
11953/// display names) keeps aliases from turning ordinary local routing into a
11954/// false fallback marker.
11955fn is_local_last_resort(appended_id: Option<&str>, candidate_id: &str) -> bool {
11956    appended_id == Some(candidate_id)
11957}
11958
11959/// Mark and announce the moment the appended model actually serves. The INFO
11960/// at append time reports only that fallback was available; this WARN reports
11961/// the materially different fact that the turn degraded onto it.
11962fn report_local_last_resort_served(
11963    appended_id: Option<&str>,
11964    candidate_id: &str,
11965    resolved_id: &str,
11966) -> bool {
11967    let served = is_local_last_resort(appended_id, candidate_id);
11968    if served {
11969        tracing::warn!(
11970            local_model = %resolved_id,
11971            "on-device last-resort model served inference turn"
11972        );
11973    }
11974    served
11975}
11976
11977/// When the whole fallback chain was exhausted by an AUTH rejection — an
11978/// expired or revoked Parslee credential (401/403, "org lookup failed",
11979/// "Authentication required", "invalid_grant") — return actionable re-auth
11980/// guidance instead of a raw HTTP status. Distinct from
11981/// [`no_backend_recovery_hint`], which covers "no credential at all": this is
11982/// the "you WERE signed in but the session lapsed and the refresh didn't
11983/// recover" case, which otherwise surfaced verbatim as `HTTP 401 Unauthorized`.
11984/// Only reached on an exhausted chain, so a single transient 401 on an
11985/// otherwise-healthy alternative never lands here.
11986fn auth_expired_recovery_hint(underlying: &str) -> Option<String> {
11987    if !is_auth_rejection_message(underlying) {
11988        return None;
11989    }
11990    Some(format!(
11991        "your Parslee session has expired or was rejected — re-authenticate:\n    \
11992         car auth login\n\
11993         Or, for a model you name yourself with --model, install a local \
11994         tool-capable one:\n    \
11995         car models pull qwen/qwen3-4b:q4_k_m\n\
11996         (underlying error: {underlying})"
11997    ))
11998}
11999
12000fn score_from_rerank_output(text: &str, model_name: &str) -> f32 {
12001    // Replace every non-alphanumeric byte with a space, lowercase,
12002    // and scan the first few whitespace-separated tokens for
12003    // "yes"/"no". This strips chat-template tags (`<|im_end|>`),
12004    // punctuation, and underscores cleanly without special-casing.
12005    let normalized: String = text
12006        .to_ascii_lowercase()
12007        .chars()
12008        .map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' })
12009        .collect();
12010    for tok in normalized.split_ascii_whitespace().take(5) {
12011        match tok {
12012            "yes" => return 1.0,
12013            "no" => return 0.0,
12014            _ => continue,
12015        }
12016    }
12017    tracing::warn!(
12018        model = %model_name,
12019        output = %text,
12020        "rerank: first tokens contain neither `yes` nor `no`; returning neutral 0.5"
12021    );
12022    0.5
12023}
12024
12025fn default_speech_voice(schema: &ModelSchema) -> Option<String> {
12026    if schema.provider == "elevenlabs" {
12027        Some("JBFqnCBsd6RMkjVDRZzb".to_string())
12028    } else if schema.name == "Kokoro-82M-6bit" || schema.name == "Kokoro-82M-bf16" {
12029        Some("af_heart".to_string())
12030    } else if schema.name == "Qwen3-TTS-12Hz-1.7B-Base-5bit" {
12031        Some("Chelsie".to_string())
12032    } else {
12033        None
12034    }
12035}
12036
12037#[allow(dead_code)] // conditionally compiled — used only on MLX-backend (macOS) snapshot-resolution paths
12038fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
12039    find_latest_huggingface_snapshot(repo_id).is_some()
12040}
12041
12042fn huggingface_repo_dir(repo_id: &str) -> PathBuf {
12043    let cache_root = std::env::var("HF_HOME")
12044        .map(PathBuf::from)
12045        .unwrap_or_else(|_| {
12046            std::env::var_os("HOME")
12047                .or_else(|| std::env::var_os("USERPROFILE"))
12048                .map(PathBuf::from)
12049                .unwrap_or_else(|| PathBuf::from("."))
12050                .join(".cache")
12051                .join("huggingface")
12052        })
12053        .join("hub");
12054    cache_root.join(format!("models--{}", repo_id.replace('/', "--")))
12055}
12056
12057fn find_latest_huggingface_snapshot(repo_id: &str) -> Option<PathBuf> {
12058    let snapshots = huggingface_repo_dir(repo_id).join("snapshots");
12059    std::fs::read_dir(snapshots)
12060        .ok()?
12061        .filter_map(Result::ok)
12062        .map(|entry| entry.path())
12063        .find(|path| path.is_dir() && snapshot_looks_ready(path))
12064}
12065
12066fn snapshot_looks_ready(path: &Path) -> bool {
12067    if path.join("config.json").exists() || path.join("model_index.json").exists() {
12068        return true;
12069    }
12070    snapshot_contains_ext(path, "safetensors")
12071}
12072
12073fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
12074    let Ok(entries) = std::fs::read_dir(root) else {
12075        return false;
12076    };
12077    entries.filter_map(Result::ok).any(|entry| {
12078        let path = entry.path();
12079        if path.is_dir() {
12080            snapshot_contains_ext(&path, ext)
12081        } else {
12082            let ext_matches = path
12083                .extension()
12084                .and_then(|value| value.to_str())
12085                .map(|value| value.eq_ignore_ascii_case(ext))
12086                .unwrap_or(false);
12087            // A matching extension only counts when the file is actually usable
12088            // — a dangling symlink into a pruned blob or a zero-length partial
12089            // must not make a snapshot look ready.
12090            ext_matches && crate::download::cache_file_usable(&path)
12091        }
12092    })
12093}
12094
12095#[allow(dead_code)] // conditionally compiled — used only on MLX-backend (macOS) media-output paths
12096fn count_files_recursive(root: &Path) -> usize {
12097    let Ok(entries) = std::fs::read_dir(root) else {
12098        return 0;
12099    };
12100    entries
12101        .filter_map(Result::ok)
12102        .map(|entry| entry.path())
12103        .map(|path| {
12104            if path.is_dir() {
12105                count_files_recursive(&path)
12106            } else if path.is_file() {
12107                1
12108            } else {
12109                0
12110            }
12111        })
12112        .sum()
12113}
12114
12115async fn download_hf_repo_snapshot(repo_id: &str) -> Result<(PathBuf, usize), InferenceError> {
12116    let api = hf_hub::api::tokio::ApiBuilder::from_env()
12117        .with_progress(false)
12118        .build()
12119        .map_err(|e| InferenceError::DownloadFailed(format!("init hf api: {e}")))?;
12120    let repo = api.model(repo_id.to_string());
12121    let info = repo
12122        .info()
12123        .await
12124        .map_err(|e| InferenceError::DownloadFailed(format!("{repo_id}: {e}")))?;
12125
12126    let snapshot_path = huggingface_repo_dir(repo_id)
12127        .join("snapshots")
12128        .join(&info.sha);
12129    let mut downloaded = 0usize;
12130    for sibling in &info.siblings {
12131        let local_path = snapshot_path.join(&sibling.rfilename);
12132        // Presence is not integrity. The shared HF cache can hold a dangling
12133        // symlink (blob pruned by another tool) or a zero-length partial write
12134        // (interrupted/out-of-disk download). Skip the re-download only when the
12135        // cached file is actually usable; otherwise fall through so hf-hub
12136        // re-fetches metadata and rewrites the blob. (Cheap check only — a full
12137        // content hash per already-present file would re-hash the whole model
12138        // on every no-op pull; deep verification lives in the self-heal path.)
12139        if crate::download::cache_file_usable(&local_path) {
12140            downloaded += 1;
12141            continue;
12142        }
12143        // Clear a stale/dangling pointer first: hf-hub's symlink recreation
12144        // returns `AlreadyExists` if the old pointer file is still on disk and
12145        // the new etag differs, surfacing as a confusing error instead of a
12146        // repair. Removing it lets hf-hub always recreate the snapshot link.
12147        let _ = std::fs::remove_file(&local_path);
12148        repo.download(&sibling.rfilename).await.map_err(|e| {
12149            InferenceError::DownloadFailed(format!("{repo_id}/{}: {e}", sibling.rfilename))
12150        })?;
12151        downloaded += 1;
12152    }
12153
12154    Ok((snapshot_path, downloaded))
12155}
12156
12157fn temp_work_dir(prefix: &str) -> Result<PathBuf, InferenceError> {
12158    let unique = SystemTime::now()
12159        .duration_since(UNIX_EPOCH)
12160        .map_err(|e| InferenceError::InferenceFailed(format!("clock error: {e}")))?
12161        .as_nanos();
12162    let dir = std::env::temp_dir().join(format!("car-inference-{prefix}-{unique}"));
12163    std::fs::create_dir_all(&dir)?;
12164    Ok(dir)
12165}
12166
12167fn ensure_parent_dir(path: &Path) -> Result<(), InferenceError> {
12168    if let Some(parent) = path.parent() {
12169        std::fs::create_dir_all(parent)?;
12170    }
12171    Ok(())
12172}
12173
12174fn requested_or_temp_output(
12175    output_path: Option<&str>,
12176    format: &str,
12177) -> Result<PathBuf, InferenceError> {
12178    if let Some(path) = output_path {
12179        return Ok(PathBuf::from(path));
12180    }
12181    let dir = temp_work_dir("audio-out")?;
12182    Ok(dir.join(format!("speech.{format}")))
12183}
12184
12185#[allow(dead_code)] // conditionally compiled — used only on MLX-backend (macOS) media-output paths
12186fn requested_or_temp_media_output(
12187    output_path: Option<&str>,
12188    format: &str,
12189    stem: &str,
12190) -> Result<PathBuf, InferenceError> {
12191    if let Some(path) = output_path {
12192        return Ok(PathBuf::from(path));
12193    }
12194    let dir = temp_work_dir(&format!("{stem}-out"))?;
12195    Ok(dir.join(format!("{stem}.{format}")))
12196}
12197
12198fn materialize_audio_output(
12199    produced: &Path,
12200    requested: Option<&str>,
12201    format: &str,
12202) -> Result<PathBuf, InferenceError> {
12203    if let Some(path) = requested {
12204        let dest = PathBuf::from(path);
12205        ensure_parent_dir(&dest)?;
12206        std::fs::copy(produced, &dest)?;
12207        Ok(dest)
12208    } else {
12209        let dest = requested_or_temp_output(None, format)?;
12210        ensure_parent_dir(&dest)?;
12211        std::fs::copy(produced, &dest)?;
12212        Ok(dest)
12213    }
12214}
12215
12216/// Synthesize `text` to WAV bytes via WinRT `Windows.Media.SpeechSynthesis`.
12217/// Mirrors car-voice's `windows_speech_tts` (the live path), but returns bytes
12218/// for the catalog synthesize path (which writes them to a file). The two can't
12219/// share code without a car-inference→car-voice cycle, and it's a small
12220/// Windows-only helper, so it's duplicated deliberately. Windows-only.
12221#[cfg(target_os = "windows")]
12222fn winrt_synthesize_wav(text: &str, voice: &str, rate: f64) -> Result<Vec<u8>, InferenceError> {
12223    use windows::core::HSTRING;
12224    use windows::Media::SpeechSynthesis::SpeechSynthesizer;
12225    use windows::Storage::Streams::DataReader;
12226
12227    let err = |m: String| InferenceError::InferenceFailed(m);
12228    let synth =
12229        SpeechSynthesizer::new().map_err(|e| err(format!("SpeechSynthesizer::new: {e}")))?;
12230    if let Ok(opts) = synth.Options() {
12231        let _ = opts.SetSpeakingRate(rate.clamp(0.5, 6.0));
12232    }
12233    if !voice.is_empty() {
12234        if let Ok(all) = SpeechSynthesizer::AllVoices() {
12235            let want = voice.to_lowercase();
12236            let count = all.Size().unwrap_or(0);
12237            for i in 0..count {
12238                if let Ok(info) = all.GetAt(i) {
12239                    if let Ok(name) = info.DisplayName() {
12240                        if name.to_string_lossy().to_lowercase().contains(&want) {
12241                            let _ = synth.SetVoice(&info);
12242                            break;
12243                        }
12244                    }
12245                }
12246            }
12247        }
12248    }
12249    let stream = synth
12250        .SynthesizeTextToStreamAsync(&HSTRING::from(text))
12251        .map_err(|e| err(format!("SynthesizeTextToStreamAsync: {e}")))?
12252        .get()
12253        .map_err(|e| err(format!("synthesize await: {e}")))?;
12254    let size = stream
12255        .Size()
12256        .map_err(|e| err(format!("stream size: {e}")))?;
12257    let input = stream
12258        .GetInputStreamAt(0)
12259        .map_err(|e| err(format!("input stream: {e}")))?;
12260    let reader =
12261        DataReader::CreateDataReader(&input).map_err(|e| err(format!("data reader: {e}")))?;
12262    reader
12263        .LoadAsync(size as u32)
12264        .map_err(|e| err(format!("load async: {e}")))?
12265        .get()
12266        .map_err(|e| err(format!("load await: {e}")))?;
12267    let mut buf = vec![0u8; size as usize];
12268    reader
12269        .ReadBytes(&mut buf)
12270        .map_err(|e| err(format!("read bytes: {e}")))?;
12271    Ok(buf)
12272}
12273
12274#[allow(dead_code)] // conditionally compiled — used only on backend-conditional transcription paths
12275fn read_transcription_result(output_prefix: &Path) -> Result<Option<String>, InferenceError> {
12276    let candidates = [
12277        output_prefix.with_extension("json"),
12278        output_prefix.to_path_buf(),
12279    ];
12280
12281    for path in candidates {
12282        if path.exists() {
12283            let contents = std::fs::read_to_string(path)?;
12284            if let Some(text) = extract_text_from_payload(&contents) {
12285                return Ok(Some(text));
12286            }
12287        }
12288    }
12289
12290    Ok(None)
12291}
12292
12293#[allow(dead_code)] // conditionally compiled — used only on backend-conditional transcription paths
12294fn extract_text_from_payload(payload: &str) -> Option<String> {
12295    let value: serde_json::Value = serde_json::from_str(payload).ok()?;
12296    if let Some(text) = value.get("text").and_then(|v| v.as_str()) {
12297        return Some(text.to_string());
12298    }
12299    if let Some(transcripts) = value.get("transcripts").and_then(|v| v.as_array()) {
12300        let joined = transcripts
12301            .iter()
12302            .filter_map(|item| item.get("text").and_then(|v| v.as_str()))
12303            .collect::<Vec<_>>()
12304            .join("\n");
12305        if !joined.is_empty() {
12306            return Some(joined);
12307        }
12308    }
12309    if let Some(items) = value.as_array() {
12310        let joined = items
12311            .iter()
12312            .filter_map(|item| {
12313                item.get("text")
12314                    .or_else(|| item.get("Content"))
12315                    .and_then(|v| v.as_str())
12316            })
12317            .collect::<Vec<_>>()
12318            .join(" ");
12319        if !joined.is_empty() {
12320            return Some(joined);
12321        }
12322    }
12323    None
12324}
12325
12326#[allow(dead_code)] // conditionally compiled — used only on backend-conditional speech-output paths
12327fn find_audio_file(output_dir: &Path) -> Result<Option<PathBuf>, InferenceError> {
12328    let mut audio_files = Vec::new();
12329    collect_audio_files(output_dir, &mut audio_files)?;
12330    audio_files.sort();
12331    Ok(audio_files.into_iter().next())
12332}
12333
12334#[allow(dead_code)] // conditionally compiled — used only on backend-conditional speech-output paths
12335fn collect_audio_files(dir: &Path, audio_files: &mut Vec<PathBuf>) -> Result<(), InferenceError> {
12336    for entry in std::fs::read_dir(dir)? {
12337        let path = entry?.path();
12338        if path.is_dir() {
12339            collect_audio_files(&path, audio_files)?;
12340        } else if matches!(
12341            path.extension().and_then(|ext| ext.to_str()),
12342            Some("wav" | "mp3" | "flac" | "pcm" | "m4a")
12343        ) {
12344            audio_files.push(path);
12345        }
12346    }
12347    Ok(())
12348}
12349
12350fn media_type_for_format(format: &str) -> String {
12351    match format.to_ascii_lowercase().as_str() {
12352        "mp3" => "audio/mpeg".to_string(),
12353        "flac" => "audio/flac".to_string(),
12354        "pcm" => "audio/L16".to_string(),
12355        "m4a" => "audio/mp4".to_string(),
12356        _ => "audio/wav".to_string(),
12357    }
12358}
12359
12360fn kokoro_lang_code(language: Option<&str>) -> &'static str {
12361    match language.unwrap_or("en").to_ascii_lowercase().as_str() {
12362        "en-gb" | "british" | "british english" => "b",
12363        "ja" | "japanese" => "j",
12364        "zh" | "zh-cn" | "mandarin" | "chinese" => "z",
12365        "es" | "spanish" => "e",
12366        "fr" | "french" => "f",
12367        _ => "a",
12368    }
12369}
12370
12371#[allow(dead_code)] // conditionally compiled — used only on backend-conditional transcription paths
12372fn normalize_lang_code(language: &str) -> String {
12373    match language.to_ascii_lowercase().as_str() {
12374        "english" | "en-us" | "en_us" => "en".to_string(),
12375        "spanish" => "es".to_string(),
12376        "french" => "fr".to_string(),
12377        "japanese" => "ja".to_string(),
12378        "chinese" | "mandarin" => "zh".to_string(),
12379        other => match other {
12380            "en" | "es" | "fr" | "ja" | "zh" => other.to_string(),
12381            _ => "en".to_string(),
12382        },
12383    }
12384}
12385
12386fn elevenlabs_auth(
12387    schema: &ModelSchema,
12388    resolve_credential: fn(&str) -> Option<String>,
12389) -> Result<(String, String), InferenceError> {
12390    match &schema.source {
12391        ModelSource::Proprietary {
12392            endpoint,
12393            auth: schema::ProprietaryAuth::ApiKeyEnv { env_var },
12394            ..
12395        } => {
12396            let key = resolve_credential(env_var).ok_or_else(|| {
12397                InferenceError::InferenceFailed(format!(
12398                    "missing API key {env_var}; set the environment variable or \
12399                     store it with `car secrets put {env_var}`"
12400                ))
12401            })?;
12402            Ok((endpoint.clone(), key))
12403        }
12404        _ => Err(InferenceError::InferenceFailed(format!(
12405            "model {} is not an ElevenLabs proprietary model",
12406            schema.id
12407        ))),
12408    }
12409}
12410
12411fn elevenlabs_output_format(format: &str) -> &'static str {
12412    match format.to_ascii_lowercase().as_str() {
12413        "mp3" => "mp3_44100_128",
12414        "pcm" => "pcm_16000",
12415        _ => "wav_44100",
12416    }
12417}
12418
12419/// Benchmark-prior files to merge, in order: `<state models dir>/`, its parent
12420/// (the state root), and an explicit `CAR_BENCHMARK_PRIORS_PATH`. Takes the
12421/// *state* models dir ([`InferenceConfig::state_models_dir`]), not the shared
12422/// weights dir, so a relocated daemon reads its own priors.
12423fn benchmark_priors_paths(state_models_dir: &Path) -> Vec<PathBuf> {
12424    let mut paths = Vec::new();
12425
12426    let direct = state_models_dir.join("benchmark_priors.json");
12427    if !paths.contains(&direct) {
12428        paths.push(direct);
12429    }
12430
12431    if let Some(parent) = state_models_dir.parent() {
12432        let parent_path = parent.join("benchmark_priors.json");
12433        if !paths.contains(&parent_path) {
12434            paths.push(parent_path);
12435        }
12436    }
12437
12438    if let Some(path) = std::env::var_os("CAR_BENCHMARK_PRIORS_PATH") {
12439        let path = PathBuf::from(path);
12440        if !paths.contains(&path) {
12441            paths.push(path);
12442        }
12443    }
12444
12445    paths
12446}
12447
12448fn load_benchmark_prior_health(
12449    state_models_dir: &Path,
12450    schemas: &[ModelSchema],
12451) -> Vec<ModelBenchmarkPriorHealth> {
12452    let mut priors = std::collections::BTreeMap::new();
12453    for path in benchmark_priors_paths(state_models_dir) {
12454        let Ok(loaded) = routing_ext::load_benchmark_priors(&path) else {
12455            continue;
12456        };
12457        for (model_id, prior) in loaded {
12458            let model_name = schemas
12459                .iter()
12460                .find(|schema| schema.id == model_id)
12461                .map(|schema| schema.name.clone());
12462            priors.insert(
12463                model_id.clone(),
12464                ModelBenchmarkPriorHealth {
12465                    model_id,
12466                    model_name,
12467                    overall_score: prior.overall_score,
12468                    overall_latency_ms: prior.overall_latency_ms,
12469                    task_scores: prior.task_scores,
12470                    task_latency_ms: prior.task_latency_ms,
12471                    source_path: path.clone(),
12472                },
12473            );
12474        }
12475    }
12476
12477    priors.into_values().collect()
12478}
12479
12480fn kokoro_runtime_fallback_enabled() -> bool {
12481    std::env::var("CAR_SPEECH_KOKORO_FALLBACK")
12482        .ok()
12483        .map(|value| {
12484            !matches!(
12485                value.trim().to_ascii_lowercase().as_str(),
12486                "0" | "false" | "off"
12487            )
12488        })
12489        .unwrap_or(true)
12490}
12491
12492fn speech_runtime_mlx_audio_spec() -> String {
12493    std::env::var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC")
12494        .ok()
12495        .filter(|value| !value.trim().is_empty())
12496        .unwrap_or_else(|| "mlx-audio==0.4.2".to_string())
12497}
12498
12499fn speech_runtime_spacy_model_spec() -> String {
12500    std::env::var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC")
12501        .ok()
12502        .filter(|value| !value.trim().is_empty())
12503        .unwrap_or_else(|| {
12504            "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()
12505        })
12506}
12507
12508#[cfg(test)]
12509pub(crate) fn run_in_isolated_test_process(test_name: &str, sentinel: &str) -> bool {
12510    static CHILD_PROCESS_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
12511
12512    if std::env::var_os(sentinel).is_some() {
12513        return true;
12514    }
12515
12516    let _child_process = CHILD_PROCESS_MUTEX
12517        .lock()
12518        .unwrap_or_else(std::sync::PoisonError::into_inner);
12519    let mut command = std::process::Command::new(std::env::current_exe().unwrap());
12520    command
12521        .arg("--exact")
12522        .arg(test_name)
12523        .arg("--nocapture")
12524        .arg("--test-threads=1")
12525        .env(sentinel, "1");
12526    for name in [
12527        car_home::ENV_VAR,
12528        "CAR_SECRETS_FILE_DIR",
12529        "CAR_TEST_NATIVE_KEYCHAIN",
12530        "CAR_AUTH_LOCK_PATH",
12531        car_auth::PARSLEE_ACCESS_TOKEN_KEY,
12532        car_auth::PARSLEE_API_BASE_KEY,
12533        crate::openrouter::API_KEY_ENV,
12534        "OPENAI_API_KEY",
12535        "ANTHROPIC_API_KEY",
12536        "GOOGLE_API_KEY",
12537        "ELEVENLABS_API_KEY",
12538        "SSL_CERT_FILE",
12539        "SSL_CERT_DIR",
12540    ] {
12541        command.env_remove(name);
12542    }
12543    // An isolated child exercises the subprocess fixtures that are most likely
12544    // to hang. `Command::output()` would wait forever while holding
12545    // CHILD_PROCESS_MUTEX, preventing every later isolated test from starting.
12546    // Keep a generous anti-hang bound (the slow fixture paths finish in under
12547    // two minutes) and capture output in files so a grandchild inheriting a
12548    // pipe cannot make the reader itself unbounded.
12549    const CHILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(300);
12550    let mut stdout = tempfile::tempfile().expect("create isolated-test stdout capture");
12551    let mut stderr = tempfile::tempfile().expect("create isolated-test stderr capture");
12552    command
12553        .stdin(std::process::Stdio::null())
12554        .stdout(stdout.try_clone().expect("clone stdout capture"))
12555        .stderr(stderr.try_clone().expect("clone stderr capture"));
12556    #[cfg(unix)]
12557    {
12558        use std::os::unix::process::CommandExt;
12559        command.process_group(0);
12560    }
12561
12562    let mut child = command.spawn().expect("spawn isolated inference test");
12563    let deadline = std::time::Instant::now() + CHILD_BUDGET;
12564    let mut timed_out = false;
12565    let status = loop {
12566        match child.try_wait().expect("poll isolated inference test") {
12567            Some(status) => break status,
12568            None if std::time::Instant::now() < deadline => {
12569                std::thread::sleep(std::time::Duration::from_millis(50));
12570            }
12571            None => {
12572                timed_out = true;
12573                #[cfg(unix)]
12574                unsafe {
12575                    libc::kill(-(child.id() as i32), libc::SIGKILL);
12576                }
12577                let _ = child.kill();
12578                break child
12579                    .wait()
12580                    .expect("reap timed-out isolated inference test");
12581            }
12582        }
12583    };
12584
12585    use std::io::{Read, Seek};
12586    stdout.rewind().expect("rewind isolated-test stdout");
12587    stderr.rewind().expect("rewind isolated-test stderr");
12588    let mut stdout_bytes = Vec::new();
12589    let mut stderr_bytes = Vec::new();
12590    stdout
12591        .read_to_end(&mut stdout_bytes)
12592        .expect("read isolated-test stdout");
12593    stderr
12594        .read_to_end(&mut stderr_bytes)
12595        .expect("read isolated-test stderr");
12596    assert!(
12597        !timed_out,
12598        "isolated {test_name} exceeded {}s and was killed\nstdout:\n{}\nstderr:\n{}",
12599        CHILD_BUDGET.as_secs(),
12600        String::from_utf8_lossy(&stdout_bytes),
12601        String::from_utf8_lossy(&stderr_bytes),
12602    );
12603    assert!(
12604        status.success(),
12605        "isolated {test_name} failed\nstdout:\n{}\nstderr:\n{}",
12606        String::from_utf8_lossy(&stdout_bytes),
12607        String::from_utf8_lossy(&stderr_bytes),
12608    );
12609    false
12610}
12611
12612#[cfg(test)]
12613mod tests {
12614    use super::*;
12615    use std::ffi::OsString;
12616    use tempfile::TempDir;
12617
12618    /// car#851: `car do --local --model mlx/qwen3-8b:4bit` hung for 57 minutes.
12619    /// The caller asked for the default 4096-token budget and the routing layer
12620    /// silently widened it to the model's advertised 32768 — which for a model
12621    /// decoded in-process is not a token budget, it is ~24 minutes of wall
12622    /// clock. Remote models still get the widening; that is what keeps a
12623    /// long-horizon tool_use argument from truncating mid-object.
12624    #[test]
12625    fn local_models_keep_the_callers_default_output_budget() {
12626        use crate::tasks::generate::DEFAULT_MAX_TOKENS;
12627
12628        let catalog = crate::registry::builtin_catalog();
12629        let local = catalog
12630            .iter()
12631            .find(|s| s.id == "mlx/qwen3-8b:4bit")
12632            .expect("mlx/qwen3-8b:4bit is a builtin catalog entry");
12633
12634        assert!(local.is_local(), "mlx/* is decoded in-process");
12635        assert_eq!(
12636            local.effective_max_output(),
12637            32_768,
12638            "the budget that made the reported turn ~24 minutes long"
12639        );
12640        assert_eq!(
12641            resolved_max_tokens(DEFAULT_MAX_TOKENS, local),
12642            DEFAULT_MAX_TOKENS,
12643            "a local model must keep the budget the caller actually asked for"
12644        );
12645
12646        // Same numbers, only the locality differs — so the widening below can
12647        // only be attributable to the model being remote.
12648        let mut remote = catalog
12649            .iter()
12650            .find(|s| !s.is_local())
12651            .expect("the builtin catalog ships remote models")
12652            .clone();
12653        remote.context_length = local.context_length;
12654        remote.max_output_tokens = local.max_output_tokens;
12655        assert!(!remote.is_local());
12656        assert_eq!(
12657            resolved_max_tokens(DEFAULT_MAX_TOKENS, &remote),
12658            32_768,
12659            "remote models still get their advertised output budget"
12660        );
12661
12662        // An explicit budget is a caller decision; never second-guess it.
12663        assert_eq!(resolved_max_tokens(512, local), 512);
12664        assert_eq!(resolved_max_tokens(512, &remote), 512);
12665
12666        let codex = catalog
12667            .iter()
12668            .find(|s| s.id == "openai/gpt-5.6-sol:high")
12669            .expect("subscription-backed Codex row is builtin");
12670        assert_eq!(
12671            resolved_max_tokens(DEFAULT_MAX_TOKENS, codex),
12672            DEFAULT_MAX_TOKENS,
12673            "an approximate instruction must not widen the default to 128K tokens"
12674        );
12675
12676        // vLLM-MLX is `is_local()` but we do not decode it — it is an HTTP
12677        // server on this machine, and it is the documented route to structured
12678        // tool calls from a local model, so it must KEEP the widening. Gating
12679        // this helper on `is_local` instead of `decodes_in_process` silently
12680        // reintroduces the tool_use truncation the widening exists to prevent.
12681        if let Some(vllm) = catalog.iter().find(|s| s.is_vllm_mlx()) {
12682            assert!(vllm.is_local(), "vLLM-MLX runs on this machine");
12683            assert!(
12684                !vllm.decodes_in_process(),
12685                "but CAR does not decode it token by token"
12686            );
12687            assert_eq!(
12688                resolved_max_tokens(DEFAULT_MAX_TOKENS, vllm),
12689                vllm.effective_max_output(),
12690            );
12691        }
12692    }
12693
12694    /// The decision that stops car#851 from paying its ceiling twice, and the
12695    /// one that stops a dead turn from reading as a successful empty one.
12696    #[test]
12697    fn a_ceiling_stop_fails_instead_of_retrying() {
12698        use EmptyPassAction::*;
12699        const CEILING: Option<&str> = Some(LOCAL_DECODE_TIMEOUT_STOP_REASON);
12700
12701        // The car#851 doubling: empty text + Auto looks exactly like a thinking
12702        // truncation, and retrying would spend the same ceiling again.
12703        assert_eq!(
12704            classify_empty_pass(true, CEILING, "", true),
12705            FailDecodeCeiling
12706        );
12707        assert_eq!(
12708            classify_empty_pass(false, CEILING, "", true),
12709            FailDecodeCeiling
12710        );
12711        // Whitespace is not output.
12712        assert_eq!(
12713            classify_empty_pass(true, CEILING, "  \n ", true),
12714            FailDecodeCeiling
12715        );
12716
12717        // The pre-existing car-releases#60 recovery still fires when the
12718        // ceiling is NOT the reason.
12719        assert_eq!(
12720            classify_empty_pass(true, Some("length"), "", true),
12721            RetryWithoutThinking
12722        );
12723        assert_eq!(
12724            classify_empty_pass(true, None, "", true),
12725            RetryWithoutThinking
12726        );
12727
12728        // Caller opted out of recovery and no ceiling: accept the empty result
12729        // rather than inventing a retry or an error.
12730        assert_eq!(classify_empty_pass(false, Some("stop"), "", true), Accept);
12731
12732        // A partial answer beats an error — a ceiling stop that produced text
12733        // or a tool call is still usable output.
12734        assert_eq!(classify_empty_pass(true, CEILING, "partial", true), Accept);
12735        assert_eq!(classify_empty_pass(true, CEILING, "", false), Accept);
12736
12737        // Collision guard: a remote provider's raw finish_reason of "timeout"
12738        // must NOT be mistaken for the local ceiling and converted into an
12739        // error naming a local wall clock and a local env var.
12740        assert_ne!(LOCAL_DECODE_TIMEOUT_STOP_REASON, "timeout");
12741        assert_eq!(
12742            classify_empty_pass(true, Some("timeout"), "", true),
12743            RetryWithoutThinking
12744        );
12745    }
12746
12747    /// Both decode loops share these two predicates. The streaming loop runs
12748    /// only against a real MLX backend, so this is the regression net for an
12749    /// inverted comparison or a heartbeat that forgets to advance. (car#851)
12750    #[test]
12751    fn decode_deadline_and_heartbeat_predicates() {
12752        use std::time::Duration;
12753        let limit = Duration::from_secs(300);
12754
12755        // No ceiling configured: never fires, however long it runs.
12756        assert!(!deadline_exceeded(Duration::from_secs(86_400), None));
12757
12758        assert!(!deadline_exceeded(Duration::from_secs(299), Some(limit)));
12759        // Inclusive at the boundary — `>`, not `>=`, would let an exactly-at-
12760        // limit decode run one more forward.
12761        assert!(deadline_exceeded(limit, Some(limit)));
12762        assert!(deadline_exceeded(Duration::from_secs(301), Some(limit)));
12763
12764        let every = Duration::from_secs(10);
12765        // First heartbeat: `last` starts at ZERO.
12766        assert!(!heartbeat_due(
12767            Duration::from_secs(9),
12768            Duration::ZERO,
12769            every
12770        ));
12771        assert!(heartbeat_due(
12772            Duration::from_secs(10),
12773            Duration::ZERO,
12774            every
12775        ));
12776        // After firing, `last` advances — no flood on the next token.
12777        assert!(!heartbeat_due(
12778            Duration::from_secs(11),
12779            Duration::from_secs(10),
12780            every
12781        ));
12782        assert!(heartbeat_due(
12783            Duration::from_secs(20),
12784            Duration::from_secs(10),
12785            every
12786        ));
12787        // saturating_sub: a `last` ahead of `elapsed` must not panic.
12788        assert!(!heartbeat_due(
12789            Duration::from_secs(5),
12790            Duration::from_secs(10),
12791            every
12792        ));
12793    }
12794
12795    #[test]
12796    fn decode_timeout_parses_with_a_safe_fallback() {
12797        let default = std::time::Duration::from_secs(DEFAULT_LOCAL_DECODE_TIMEOUT_SECS);
12798        assert_eq!(parse_decode_timeout(None), Some(default));
12799        assert_eq!(
12800            parse_decode_timeout(Some(" 45 ")),
12801            Some(std::time::Duration::from_secs(45))
12802        );
12803        assert_eq!(
12804            parse_decode_timeout(Some("0")),
12805            None,
12806            "0 disables the ceiling"
12807        );
12808        assert_eq!(
12809            parse_decode_timeout(Some("banana")),
12810            Some(default),
12811            "garbage must fall back to the default, never silently disable it"
12812        );
12813    }
12814
12815    /// The other half of car#851: even with a sane budget, the decode loop's
12816    /// only bound was `max_tokens`, and it logged nothing for its whole
12817    /// duration — so a runaway was indistinguishable from a wedged process.
12818    /// A decoder that never emits an eos id must now come back on the clock.
12819    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12820    #[test]
12821    fn local_decode_stops_at_the_wall_clock_ceiling() {
12822        /// Always samples token 7; 0 is the only eos id, so this never stops
12823        /// on its own. 5 ms per forward stands in for a large slow model.
12824        struct NeverStops;
12825
12826        impl crate::backend::local::TextDecoder for NeverStops {
12827            fn encode(&self, _text: &str) -> Result<Vec<u32>, InferenceError> {
12828                Ok(vec![1, 2, 3])
12829            }
12830            fn decode(&self, tokens: &[u32]) -> Result<String, InferenceError> {
12831                Ok("x".repeat(tokens.len()))
12832            }
12833            fn forward(
12834                &mut self,
12835                _tokens: &[u32],
12836                _pos: usize,
12837            ) -> Result<Vec<f32>, InferenceError> {
12838                std::thread::sleep(std::time::Duration::from_millis(5));
12839                let mut logits = vec![0.0f32; 16];
12840                logits[7] = 10.0;
12841                Ok(logits)
12842            }
12843            fn eos_ids(&self) -> Vec<u32> {
12844                vec![0]
12845            }
12846            fn context_length(&self) -> usize {
12847                4096
12848            }
12849            fn clear_kv_cache(&mut self) {}
12850        }
12851
12852        // 100_000 tokens at 5 ms each is over eight minutes of decode if the
12853        // budget is the only thing bounding the loop.
12854        let params = GenerateParams {
12855            max_tokens: 100_000,
12856            temperature: 0.0,
12857            ..Default::default()
12858        };
12859        let ceiling = std::time::Duration::from_millis(300);
12860
12861        let mut backend = NeverStops;
12862        let started = std::time::Instant::now();
12863        let generated = match InferenceEngine::drive_generation_with_timeout(
12864            &mut backend,
12865            "anything",
12866            &params,
12867            Some(ceiling),
12868        ) {
12869            Ok(generated) => generated,
12870            // `DriveError` is not `Debug`, so unwrap the inner error by hand.
12871            Err(e) => panic!(
12872                "a decode cut short by the ceiling returns its partial text, not an error: {}",
12873                e.into_inner()
12874            ),
12875        };
12876        let elapsed = started.elapsed();
12877
12878        assert_eq!(
12879            generated.stop_reason.as_deref(),
12880            Some(LOCAL_DECODE_TIMEOUT_STOP_REASON),
12881            "the caller must be able to tell a deadline stop from a clean finish"
12882        );
12883        assert!(
12884            InferenceResult {
12885                text: generated.text.clone(),
12886                bounding_boxes: Vec::new(),
12887                tool_calls: vec![],
12888                trace_id: String::new(),
12889                model_used: String::new(),
12890                model_identity: Default::default(),
12891                latency_ms: 0,
12892                time_to_first_token_ms: None,
12893                usage: None,
12894                provider_output_items: Vec::new(),
12895                thinking: Vec::new(),
12896                stop_reason: generated.stop_reason.clone(),
12897                auth_fallback_from: None,
12898                local_last_resort: false,
12899                fallback_from: Vec::new(),
12900            }
12901            .was_truncated(),
12902            "a ceiling stop is a cut-short answer, not a complete one"
12903        );
12904        assert!(
12905            generated.completion_tokens > 0,
12906            "the partial response is kept, not discarded"
12907        );
12908        assert!(
12909            generated.completion_tokens < params.max_tokens,
12910            "the loop stopped on the clock, not by exhausting the budget"
12911        );
12912        assert!(
12913            elapsed < std::time::Duration::from_secs(30),
12914            "bounded in wall clock; took {elapsed:?}"
12915        );
12916    }
12917
12918    struct RestoredEnvironment(Vec<(&'static str, Option<OsString>)>);
12919
12920    impl RestoredEnvironment {
12921        fn capture(names: &[&'static str]) -> Self {
12922            Self(
12923                names
12924                    .iter()
12925                    .map(|name| (*name, std::env::var_os(name)))
12926                    .collect(),
12927            )
12928        }
12929    }
12930
12931    impl Drop for RestoredEnvironment {
12932        fn drop(&mut self) {
12933            for (name, value) in &self.0 {
12934                unsafe {
12935                    match value {
12936                        Some(value) => std::env::set_var(name, value),
12937                        None => std::env::remove_var(name),
12938                    }
12939                }
12940            }
12941        }
12942    }
12943
12944    struct FixtureLocalOffload {
12945        emit_done: bool,
12946    }
12947
12948    // Gated to match its only consumer,
12949    // `exact_model_id_nonstream_bypasses_mlx_equivalent_substitution`. Ungated,
12950    // this whole cluster is dead code everywhere the MLX test is compiled out,
12951    // which `-D warnings` turns into a build failure on the Linux runner.
12952    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12953    struct ExactPinCaptureOffload {
12954        dispatched_models: std::sync::Mutex<Vec<String>>,
12955    }
12956
12957    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12958    impl ExactPinCaptureOffload {
12959        fn new() -> Self {
12960            Self {
12961                dispatched_models: std::sync::Mutex::new(Vec::new()),
12962            }
12963        }
12964
12965        fn dispatched_models(&self) -> Vec<String> {
12966            self.dispatched_models.lock().unwrap().clone()
12967        }
12968    }
12969
12970    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12971    #[async_trait::async_trait]
12972    impl crate::offload::LocalGenerationOffload for ExactPinCaptureOffload {
12973        async fn generate(
12974            &self,
12975            request: GenerateRequest,
12976        ) -> Result<InferenceResult, InferenceError> {
12977            let model_id = request.model.expect("resolved worker model");
12978            self.dispatched_models
12979                .lock()
12980                .unwrap()
12981                .push(model_id.clone());
12982            Ok(InferenceResult {
12983                text: "exact pin".into(),
12984                tool_calls: vec![],
12985                bounding_boxes: vec![],
12986                trace_id: "worker-trace".into(),
12987                model_used: model_id,
12988                model_identity: Default::default(),
12989                latency_ms: 0,
12990                time_to_first_token_ms: None,
12991                usage: None,
12992                provider_output_items: vec![],
12993                thinking: vec![],
12994                stop_reason: Some("stop".into()),
12995                auth_fallback_from: None,
12996                local_last_resort: false,
12997                fallback_from: Vec::new(),
12998            })
12999        }
13000
13001        async fn stream(
13002            &self,
13003            request: GenerateRequest,
13004        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13005            let model_id = request.model.expect("resolved worker model");
13006            self.dispatched_models.lock().unwrap().push(model_id);
13007            let (tx, rx) = tokio::sync::mpsc::channel(2);
13008            tx.send(StreamEvent::Done {
13009                text: "exact pin".into(),
13010                tool_calls: vec![],
13011            })
13012            .await
13013            .unwrap();
13014            Ok(rx)
13015        }
13016    }
13017
13018    struct ReleaseRetryOffload {
13019        calls: std::sync::Mutex<Vec<String>>,
13020        model_id: String,
13021        resident: bool,
13022        release_acknowledged: bool,
13023    }
13024
13025    struct SequencedRetryProbe {
13026        calls: Arc<std::sync::atomic::AtomicUsize>,
13027        first_available_mb: u64,
13028        subsequent_available_mb: u64,
13029    }
13030
13031    impl crate::resource_policy::LiveMemoryProbe for SequencedRetryProbe {
13032        fn available_memory_mb(
13033            &self,
13034        ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
13035            let call = self
13036                .calls
13037                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
13038            Ok(Some(if call == 0 {
13039                self.first_available_mb
13040            } else {
13041                self.subsequent_available_mb
13042            }))
13043        }
13044    }
13045
13046    #[async_trait::async_trait]
13047    impl crate::offload::LocalGenerationOffload for ReleaseRetryOffload {
13048        async fn generate(
13049            &self,
13050            _request: GenerateRequest,
13051        ) -> Result<InferenceResult, InferenceError> {
13052            unreachable!("release-and-retry fixture")
13053        }
13054
13055        async fn stream(
13056            &self,
13057            _request: GenerateRequest,
13058        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13059            unreachable!("release-and-retry fixture")
13060        }
13061
13062        async fn resident_models(&self) -> Vec<String> {
13063            self.resident
13064                .then(|| self.model_id.clone())
13065                .into_iter()
13066                .collect()
13067        }
13068
13069        fn resident_allocation_id(&self, model_id: &str) -> Option<String> {
13070            Some(format!("worker:retry-fixture:{model_id}"))
13071        }
13072
13073        async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13074            self.calls
13075                .lock()
13076                .unwrap()
13077                .push(format!("release:{model_id}"));
13078            Ok(self.release_acknowledged)
13079        }
13080    }
13081
13082    struct RetiringReleaseOffload {
13083        calls: std::sync::Mutex<Vec<String>>,
13084        model_id: String,
13085        allocation_id: String,
13086        resident: std::sync::atomic::AtomicBool,
13087        coordinator: Arc<crate::resource_policy::LocalAdmissionCoordinator>,
13088    }
13089
13090    #[async_trait::async_trait]
13091    impl crate::offload::LocalGenerationOffload for RetiringReleaseOffload {
13092        async fn generate(
13093            &self,
13094            _request: GenerateRequest,
13095        ) -> Result<InferenceResult, InferenceError> {
13096            unreachable!("retiring-release fixture")
13097        }
13098
13099        async fn stream(
13100            &self,
13101            _request: GenerateRequest,
13102        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13103            unreachable!("retiring-release fixture")
13104        }
13105
13106        async fn resident_models(&self) -> Vec<String> {
13107            self.resident
13108                .load(std::sync::atomic::Ordering::Acquire)
13109                .then(|| self.model_id.clone())
13110                .into_iter()
13111                .collect()
13112        }
13113
13114        fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
13115            Some(self.allocation_id.clone())
13116        }
13117
13118        async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13119            self.calls
13120                .lock()
13121                .unwrap()
13122                .push(format!("release:{model_id}"));
13123            self.coordinator
13124                .mark_teardown_pending_allocation(model_id, &self.allocation_id);
13125            self.coordinator
13126                .finish_teardown_allocation(model_id, &self.allocation_id);
13127            self.resident
13128                .store(false, std::sync::atomic::Ordering::Release);
13129            Ok(true)
13130        }
13131    }
13132
13133    #[tokio::test]
13134    async fn release_and_retry_releases_resident_worker_after_live_memory_refusal() {
13135        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13136        let root = tempfile::tempdir().unwrap();
13137        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13138        let schema = crate::registry::builtin_catalog()
13139            .into_iter()
13140            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13141            .unwrap();
13142        let allocation_id = format!("worker:retry-fixture:{}", schema.id);
13143        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13144        let coordinator = Arc::new(
13145            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13146                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13147                metal_mac_for_fit(32),
13148                Arc::new(SequencedRetryProbe {
13149                    calls: probe_calls.clone(),
13150                    first_available_mb: 1_000,
13151                    subsequent_available_mb: 1_048_576,
13152                }),
13153            ),
13154        );
13155        coordinator.mark_resident_allocation(&schema.id, &allocation_id, 4_800);
13156        engine.local_admission = coordinator.clone();
13157        let offload = Arc::new(RetiringReleaseOffload {
13158            calls: std::sync::Mutex::new(Vec::new()),
13159            model_id: schema.id.clone(),
13160            allocation_id,
13161            resident: std::sync::atomic::AtomicBool::new(true),
13162            coordinator,
13163        });
13164        crate::offload::set_local_offload(Some(offload.clone()));
13165
13166        let result = engine
13167            .reserve_local_request_with_worker_retry(&schema, 9_000)
13168            .await;
13169        crate::offload::set_local_offload(None);
13170
13171        assert!(
13172            result.is_ok(),
13173            "release followed by a second probe should admit"
13174        );
13175        assert_eq!(
13176            *offload.calls.lock().unwrap(),
13177            vec![format!("release:{}", schema.id)]
13178        );
13179        assert_eq!(
13180            probe_calls.load(std::sync::atomic::Ordering::Relaxed),
13181            2,
13182            "admission must probe exactly once before and once after release"
13183        );
13184    }
13185
13186    #[tokio::test]
13187    async fn release_and_retry_ignores_sibling_allocation_after_exact_worker_retirement() {
13188        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13189        let root = tempfile::tempdir().unwrap();
13190        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13191        let schema = crate::registry::builtin_catalog()
13192            .into_iter()
13193            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13194            .unwrap();
13195        let worker_allocation_id = format!("worker:sibling-test:{}", schema.id);
13196        let sibling_allocation_id = format!("cache:sibling-test:{}", schema.id);
13197        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13198        let coordinator = Arc::new(
13199            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13200                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13201                metal_mac_for_fit(32),
13202                Arc::new(SequencedRetryProbe {
13203                    calls: probe_calls.clone(),
13204                    first_available_mb: 1_000,
13205                    subsequent_available_mb: 1_048_576,
13206                }),
13207            ),
13208        );
13209        coordinator.mark_resident_allocation(&schema.id, &worker_allocation_id, 4_800);
13210        coordinator.mark_resident_allocation(&schema.id, &sibling_allocation_id, 500);
13211        engine.local_admission = coordinator.clone();
13212        let offload = Arc::new(RetiringReleaseOffload {
13213            calls: std::sync::Mutex::new(Vec::new()),
13214            model_id: schema.id.clone(),
13215            allocation_id: worker_allocation_id.clone(),
13216            resident: std::sync::atomic::AtomicBool::new(true),
13217            coordinator: coordinator.clone(),
13218        });
13219        crate::offload::set_local_offload(Some(offload.clone()));
13220
13221        let result = engine
13222            .reserve_local_request_with_worker_retry(&schema, 9_000)
13223            .await;
13224        crate::offload::set_local_offload(None);
13225
13226        assert!(
13227            result.is_ok(),
13228            "an unrelated resident allocation must not block the worker retry"
13229        );
13230        assert_eq!(
13231            coordinator.resident_allocation_ids(&schema.id),
13232            vec![sibling_allocation_id]
13233        );
13234        assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 2);
13235        assert_eq!(
13236            *offload.calls.lock().unwrap(),
13237            vec![format!("release:{}", schema.id)]
13238        );
13239    }
13240
13241    struct UnretiredReleaseOffload {
13242        calls: std::sync::Mutex<Vec<String>>,
13243        model_id: String,
13244        allocation_id: String,
13245        resident: std::sync::atomic::AtomicBool,
13246    }
13247
13248    #[async_trait::async_trait]
13249    impl crate::offload::LocalGenerationOffload for UnretiredReleaseOffload {
13250        async fn generate(
13251            &self,
13252            _request: GenerateRequest,
13253        ) -> Result<InferenceResult, InferenceError> {
13254            unreachable!("unretired-release fixture")
13255        }
13256
13257        async fn stream(
13258            &self,
13259            _request: GenerateRequest,
13260        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13261            unreachable!("unretired-release fixture")
13262        }
13263
13264        async fn resident_models(&self) -> Vec<String> {
13265            self.resident
13266                .load(std::sync::atomic::Ordering::Acquire)
13267                .then(|| self.model_id.clone())
13268                .into_iter()
13269                .collect()
13270        }
13271
13272        fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
13273            Some(self.allocation_id.clone())
13274        }
13275
13276        async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13277            self.calls
13278                .lock()
13279                .unwrap()
13280                .push(format!("release:{model_id}"));
13281            self.resident
13282                .store(false, std::sync::atomic::Ordering::Release);
13283            Ok(true)
13284        }
13285    }
13286
13287    #[tokio::test]
13288    async fn release_and_retry_rejects_ack_without_accounting_retirement() {
13289        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13290        let warnings_before =
13291            UNRETIRED_RELEASE_WARNING_COUNT.load(std::sync::atomic::Ordering::Relaxed);
13292        let root = tempfile::tempdir().unwrap();
13293        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13294        let schema = crate::registry::builtin_catalog()
13295            .into_iter()
13296            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13297            .unwrap();
13298        let allocation_id = format!("worker:phantom:{}", schema.id);
13299        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13300        let coordinator = Arc::new(
13301            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13302                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13303                metal_mac_for_fit(32),
13304                Arc::new(SequencedRetryProbe {
13305                    calls: probe_calls.clone(),
13306                    first_available_mb: 1_000,
13307                    subsequent_available_mb: 8_000,
13308                }),
13309            ),
13310        );
13311        coordinator.mark_resident_allocation(&schema.id, &allocation_id, 4_800);
13312        engine.local_admission = coordinator;
13313        let offload = Arc::new(UnretiredReleaseOffload {
13314            calls: std::sync::Mutex::new(Vec::new()),
13315            model_id: schema.id.clone(),
13316            allocation_id,
13317            resident: std::sync::atomic::AtomicBool::new(true),
13318        });
13319        crate::offload::set_local_offload(Some(offload.clone()));
13320
13321        let result = engine
13322            .reserve_local_request_with_worker_retry(&schema, 9_000)
13323            .await;
13324        crate::offload::set_local_offload(None);
13325
13326        assert!(matches!(
13327            result,
13328            Err(InferenceError::LocalResourceBlocked { preflight, .. })
13329                if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13330        ));
13331        assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13332        assert_eq!(
13333            UNRETIRED_RELEASE_WARNING_COUNT.load(std::sync::atomic::Ordering::Relaxed),
13334            warnings_before + 1,
13335            "the refused retry must emit the stale-residency warning"
13336        );
13337        assert!(engine.local_admission.is_resident(&schema.id));
13338        assert!(offload.resident_models().await.is_empty());
13339        assert_eq!(
13340            *offload.calls.lock().unwrap(),
13341            vec![format!("release:{}", schema.id)]
13342        );
13343    }
13344
13345    struct PendingTeardownAckOffload {
13346        calls: std::sync::Mutex<Vec<String>>,
13347        model_id: String,
13348        allocation_id: String,
13349        resident: std::sync::atomic::AtomicBool,
13350        coordinator: Arc<crate::resource_policy::LocalAdmissionCoordinator>,
13351    }
13352
13353    #[async_trait::async_trait]
13354    impl crate::offload::LocalGenerationOffload for PendingTeardownAckOffload {
13355        async fn generate(
13356            &self,
13357            _request: GenerateRequest,
13358        ) -> Result<InferenceResult, InferenceError> {
13359            unreachable!("pending-teardown ACK fixture")
13360        }
13361
13362        async fn stream(
13363            &self,
13364            _request: GenerateRequest,
13365        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13366            unreachable!("pending-teardown ACK fixture")
13367        }
13368
13369        async fn resident_models(&self) -> Vec<String> {
13370            self.resident
13371                .load(std::sync::atomic::Ordering::Acquire)
13372                .then(|| self.model_id.clone())
13373                .into_iter()
13374                .collect()
13375        }
13376
13377        fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
13378            Some(self.allocation_id.clone())
13379        }
13380
13381        async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13382            self.calls
13383                .lock()
13384                .unwrap()
13385                .push(format!("release:{model_id}"));
13386            self.coordinator
13387                .mark_teardown_pending_allocation_with_charge(
13388                    model_id,
13389                    &self.allocation_id,
13390                    4_800 * 1024 * 1024,
13391                );
13392            self.resident
13393                .store(false, std::sync::atomic::Ordering::Release);
13394            Ok(true)
13395        }
13396    }
13397
13398    #[tokio::test]
13399    async fn release_and_retry_rejects_ack_with_exact_worker_pending_teardown() {
13400        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13401        let warnings_before =
13402            UNRETIRED_RELEASE_WARNING_COUNT.load(std::sync::atomic::Ordering::Relaxed);
13403        let root = tempfile::tempdir().unwrap();
13404        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13405        let schema = crate::registry::builtin_catalog()
13406            .into_iter()
13407            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13408            .unwrap();
13409        let allocation_id = format!("worker:pending-test:{}", schema.id);
13410        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13411        let coordinator = Arc::new(
13412            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13413                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13414                metal_mac_for_fit(32),
13415                Arc::new(SequencedRetryProbe {
13416                    calls: probe_calls.clone(),
13417                    first_available_mb: 1_000,
13418                    subsequent_available_mb: 8_000,
13419                }),
13420            ),
13421        );
13422        engine.local_admission = coordinator.clone();
13423        let offload = Arc::new(PendingTeardownAckOffload {
13424            calls: std::sync::Mutex::new(Vec::new()),
13425            model_id: schema.id.clone(),
13426            allocation_id: allocation_id.clone(),
13427            resident: std::sync::atomic::AtomicBool::new(true),
13428            coordinator: coordinator.clone(),
13429        });
13430        crate::offload::set_local_offload(Some(offload.clone()));
13431
13432        let result = engine
13433            .reserve_local_request_with_worker_retry(&schema, 9_000)
13434            .await;
13435        crate::offload::set_local_offload(None);
13436
13437        assert!(matches!(
13438            result,
13439            Err(InferenceError::LocalResourceBlocked { preflight, .. })
13440                if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13441        ));
13442        assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13443        assert_eq!(
13444            UNRETIRED_RELEASE_WARNING_COUNT.load(std::sync::atomic::Ordering::Relaxed),
13445            warnings_before + 1,
13446            "the pending exact allocation must emit the fail-closed warning"
13447        );
13448        assert_eq!(
13449            coordinator.resident_allocation_ids(&schema.id),
13450            vec![allocation_id]
13451        );
13452        assert!(offload.resident_models().await.is_empty());
13453        assert_eq!(
13454            *offload.calls.lock().unwrap(),
13455            vec![format!("release:{}", schema.id)]
13456        );
13457    }
13458
13459    struct MissingAllocationIdOffload {
13460        calls: std::sync::Mutex<Vec<String>>,
13461        model_id: String,
13462    }
13463
13464    #[async_trait::async_trait]
13465    impl crate::offload::LocalGenerationOffload for MissingAllocationIdOffload {
13466        async fn generate(
13467            &self,
13468            _request: GenerateRequest,
13469        ) -> Result<InferenceResult, InferenceError> {
13470            unreachable!("missing allocation-id fixture")
13471        }
13472
13473        async fn stream(
13474            &self,
13475            _request: GenerateRequest,
13476        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13477            unreachable!("missing allocation-id fixture")
13478        }
13479
13480        async fn resident_models(&self) -> Vec<String> {
13481            vec![self.model_id.clone()]
13482        }
13483
13484        async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13485            self.calls
13486                .lock()
13487                .unwrap()
13488                .push(format!("release:{model_id}"));
13489            Ok(true)
13490        }
13491    }
13492
13493    #[tokio::test]
13494    async fn release_and_retry_without_scoped_allocation_id_fails_closed() {
13495        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13496        let root = tempfile::tempdir().unwrap();
13497        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13498        let schema = crate::registry::builtin_catalog()
13499            .into_iter()
13500            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13501            .unwrap();
13502        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13503        engine.local_admission = Arc::new(
13504            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13505                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13506                metal_mac_for_fit(32),
13507                Arc::new(SequencedRetryProbe {
13508                    calls: probe_calls.clone(),
13509                    first_available_mb: 1_000,
13510                    subsequent_available_mb: 8_000,
13511                }),
13512            ),
13513        );
13514        let offload = Arc::new(MissingAllocationIdOffload {
13515            calls: std::sync::Mutex::new(Vec::new()),
13516            model_id: schema.id.clone(),
13517        });
13518        crate::offload::set_local_offload(Some(offload.clone()));
13519
13520        let result = engine
13521            .reserve_local_request_with_worker_retry(&schema, 9_000)
13522            .await;
13523        crate::offload::set_local_offload(None);
13524
13525        assert!(matches!(
13526            result,
13527            Err(InferenceError::LocalResourceBlocked { preflight, .. })
13528                if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13529        ));
13530        assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13531        assert!(offload.calls.lock().unwrap().is_empty());
13532    }
13533
13534    struct ReplacingResidentOffload {
13535        calls: std::sync::Mutex<Vec<String>>,
13536        model_id: String,
13537        allocation_id: String,
13538        coordinator: Arc<crate::resource_policy::LocalAdmissionCoordinator>,
13539    }
13540
13541    #[async_trait::async_trait]
13542    impl crate::offload::LocalGenerationOffload for ReplacingResidentOffload {
13543        async fn generate(
13544            &self,
13545            _request: GenerateRequest,
13546        ) -> Result<InferenceResult, InferenceError> {
13547            unreachable!("replacement-resident fixture")
13548        }
13549
13550        async fn stream(
13551            &self,
13552            _request: GenerateRequest,
13553        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13554            unreachable!("replacement-resident fixture")
13555        }
13556
13557        async fn resident_models(&self) -> Vec<String> {
13558            vec![self.model_id.clone()]
13559        }
13560
13561        fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
13562            Some(self.allocation_id.clone())
13563        }
13564
13565        async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13566            self.calls
13567                .lock()
13568                .unwrap()
13569                .push(format!("release:{model_id}"));
13570            // Model the real confirmed-exit accounting transaction, followed
13571            // by a new worker generation publishing under the reused scope id
13572            // before the release await returns to the retry helper.
13573            self.coordinator
13574                .mark_teardown_pending_allocation(model_id, &self.allocation_id);
13575            self.coordinator
13576                .finish_teardown_allocation(model_id, &self.allocation_id);
13577            self.coordinator
13578                .mark_resident_allocation(model_id, &self.allocation_id, 4_800);
13579            Ok(true)
13580        }
13581    }
13582
13583    #[tokio::test]
13584    async fn release_and_retry_same_id_replacement_blocks_retry_without_erasing_accounting() {
13585        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13586        let root = tempfile::tempdir().unwrap();
13587        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13588        let schema = crate::registry::builtin_catalog()
13589            .into_iter()
13590            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13591            .unwrap();
13592        let allocation_id = format!("worker:reused-scope:{}", schema.id);
13593        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13594        let coordinator = Arc::new(
13595            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13596                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13597                metal_mac_for_fit(32),
13598                Arc::new(SequencedRetryProbe {
13599                    calls: probe_calls.clone(),
13600                    first_available_mb: 1_000,
13601                    subsequent_available_mb: 8_000,
13602                }),
13603            ),
13604        );
13605        coordinator.mark_resident_allocation(&schema.id, &allocation_id, 4_800);
13606        engine.local_admission = coordinator.clone();
13607        let offload = Arc::new(ReplacingResidentOffload {
13608            calls: std::sync::Mutex::new(Vec::new()),
13609            model_id: schema.id.clone(),
13610            allocation_id,
13611            coordinator,
13612        });
13613        crate::offload::set_local_offload(Some(offload.clone()));
13614
13615        let result = engine
13616            .reserve_local_request_with_worker_retry(&schema, 9_000)
13617            .await;
13618        crate::offload::set_local_offload(None);
13619
13620        assert!(matches!(
13621            result,
13622            Err(InferenceError::LocalResourceBlocked { preflight, .. })
13623                if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13624        ));
13625        assert!(engine.local_admission.is_resident(&schema.id));
13626        assert_eq!(
13627            *offload.calls.lock().unwrap(),
13628            vec![format!("release:{}", schema.id)]
13629        );
13630        assert_eq!(
13631            probe_calls.load(std::sync::atomic::Ordering::Relaxed),
13632            1,
13633            "replacement residency must stop the retry before a second probe"
13634        );
13635    }
13636
13637    #[tokio::test]
13638    async fn release_and_retry_nonresident_worker_returns_original_refusal() {
13639        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13640        let root = tempfile::tempdir().unwrap();
13641        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13642        let schema = crate::registry::builtin_catalog()
13643            .into_iter()
13644            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13645            .unwrap();
13646        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13647        engine.local_admission = Arc::new(
13648            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13649                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13650                metal_mac_for_fit(32),
13651                Arc::new(SequencedRetryProbe {
13652                    calls: probe_calls.clone(),
13653                    first_available_mb: 1_000,
13654                    subsequent_available_mb: 1_048_576,
13655                }),
13656            ),
13657        );
13658        let offload = Arc::new(ReleaseRetryOffload {
13659            calls: std::sync::Mutex::new(Vec::new()),
13660            model_id: schema.id.clone(),
13661            resident: false,
13662            release_acknowledged: true,
13663        });
13664        crate::offload::set_local_offload(Some(offload.clone()));
13665
13666        let result = engine
13667            .reserve_local_request_with_worker_retry(&schema, 9_000)
13668            .await;
13669        crate::offload::set_local_offload(None);
13670
13671        assert!(matches!(
13672            result,
13673            Err(InferenceError::LocalResourceBlocked { preflight, .. })
13674                if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13675        ));
13676        assert!(offload.calls.lock().unwrap().is_empty());
13677        assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13678    }
13679
13680    #[tokio::test]
13681    async fn release_and_retry_unacknowledged_release_does_not_reprobe() {
13682        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13683        let root = tempfile::tempdir().unwrap();
13684        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13685        let schema = crate::registry::builtin_catalog()
13686            .into_iter()
13687            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13688            .unwrap();
13689        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13690        engine.local_admission = Arc::new(
13691            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13692                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13693                metal_mac_for_fit(32),
13694                Arc::new(SequencedRetryProbe {
13695                    calls: probe_calls.clone(),
13696                    first_available_mb: 1_000,
13697                    subsequent_available_mb: 1_048_576,
13698                }),
13699            ),
13700        );
13701        let offload = Arc::new(ReleaseRetryOffload {
13702            calls: std::sync::Mutex::new(Vec::new()),
13703            model_id: schema.id.clone(),
13704            resident: true,
13705            release_acknowledged: false,
13706        });
13707        crate::offload::set_local_offload(Some(offload.clone()));
13708
13709        let result = engine
13710            .reserve_local_request_with_worker_retry(&schema, 9_000)
13711            .await;
13712        crate::offload::set_local_offload(None);
13713
13714        assert!(matches!(
13715            result,
13716            Err(InferenceError::LocalResourceBlocked { preflight, .. })
13717                if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13718        ));
13719        assert_eq!(
13720            *offload.calls.lock().unwrap(),
13721            vec![format!("release:{}", schema.id)]
13722        );
13723        assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13724    }
13725
13726    #[tokio::test]
13727    async fn release_and_retry_allowed_first_verdict_never_releases() {
13728        let _offload_guard = crate::offload::test_offload_lock().lock().await;
13729        let root = tempfile::tempdir().unwrap();
13730        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13731        let schema = crate::registry::builtin_catalog()
13732            .into_iter()
13733            .find(|model| model.id == "mlx/qwen3-8b:4bit")
13734            .unwrap();
13735        let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13736        engine.local_admission = Arc::new(
13737            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13738                crate::resource_policy::ResourcePolicy::local_focused(),
13739                metal_mac_for_fit(64),
13740                Arc::new(SequencedRetryProbe {
13741                    calls: probe_calls.clone(),
13742                    first_available_mb: 1_048_576,
13743                    subsequent_available_mb: 1_048_576,
13744                }),
13745            ),
13746        );
13747        let offload = Arc::new(ReleaseRetryOffload {
13748            calls: std::sync::Mutex::new(Vec::new()),
13749            model_id: schema.id.clone(),
13750            resident: true,
13751            release_acknowledged: true,
13752        });
13753        crate::offload::set_local_offload(Some(offload.clone()));
13754
13755        let result = engine
13756            .reserve_local_request_with_worker_retry(&schema, 9_000)
13757            .await;
13758        crate::offload::set_local_offload(None);
13759
13760        assert!(result.is_ok());
13761        assert!(offload.calls.lock().unwrap().is_empty());
13762        assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13763    }
13764
13765    struct ReconcileOffload {
13766        calls: std::sync::Mutex<Vec<String>>,
13767        release_acknowledged: bool,
13768    }
13769
13770    #[async_trait::async_trait]
13771    impl crate::offload::LocalGenerationOffload for ReconcileOffload {
13772        async fn generate(
13773            &self,
13774            _request: GenerateRequest,
13775        ) -> Result<InferenceResult, InferenceError> {
13776            unreachable!("residency reconciliation fixture")
13777        }
13778
13779        async fn stream(
13780            &self,
13781            _request: GenerateRequest,
13782        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13783            unreachable!("residency reconciliation fixture")
13784        }
13785
13786        fn resident_allocation_id(&self, model_id: &str) -> Option<String> {
13787            self.calls
13788                .lock()
13789                .unwrap()
13790                .push(format!("allocation:{model_id}"));
13791            Some(format!("worker:{model_id}:generation-7"))
13792        }
13793
13794        async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13795            self.calls
13796                .lock()
13797                .unwrap()
13798                .push(format!("release:{model_id}"));
13799            Ok(self.release_acknowledged)
13800        }
13801    }
13802
13803    fn reconciliation_reservation() -> (
13804        Arc<crate::resource_policy::LocalAdmissionCoordinator>,
13805        crate::resource_policy::LocalLoadReservation,
13806    ) {
13807        struct FixedProbe;
13808        impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
13809            fn available_memory_mb(
13810                &self,
13811            ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
13812                Ok(Some(24_000))
13813            }
13814        }
13815        let coordinator = Arc::new(
13816            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13817                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13818                crate::hardware::HardwareInfo {
13819                    total_ram_mb: 32 * 1024,
13820                    ..crate::hardware::HardwareInfo::detect()
13821                },
13822                Arc::new(FixedProbe),
13823            ),
13824        );
13825        let reservation = coordinator
13826            .reserve_measured_host_allocation(
13827                "expected/model",
13828                "expected/model#request",
13829                1024 * 1024,
13830                0,
13831            )
13832            .unwrap();
13833        (coordinator, reservation)
13834    }
13835
13836    #[tokio::test]
13837    async fn worker_residency_reconciliation_releases_mismatched_ack_before_returning_error() {
13838        let offload = ReconcileOffload {
13839            calls: std::sync::Mutex::new(Vec::new()),
13840            release_acknowledged: true,
13841        };
13842        let (_coordinator, mut reservation) = reconciliation_reservation();
13843        let error = InferenceEngine::reconcile_worker_residency(
13844            &offload,
13845            "expected/model",
13846            &crate::offload::LocalWorkerResidency {
13847                model_id: "wrong/model".into(),
13848                measured_weights_bytes: 2 * 1024 * 1024,
13849            },
13850            backend_cache::BackendRetention::Resident,
13851            &mut reservation,
13852        )
13853        .await
13854        .unwrap_err();
13855        assert!(error.to_string().contains("wrong/model"));
13856        assert_eq!(
13857            *offload.calls.lock().unwrap(),
13858            vec!["release:wrong/model".to_string()]
13859        );
13860    }
13861
13862    #[tokio::test]
13863    async fn worker_residency_reconciliation_publishes_exact_owner_for_matching_ack() {
13864        let offload = ReconcileOffload {
13865            calls: std::sync::Mutex::new(Vec::new()),
13866            release_acknowledged: false,
13867        };
13868        let (coordinator, mut reservation) = reconciliation_reservation();
13869        InferenceEngine::reconcile_worker_residency(
13870            &offload,
13871            "expected/model",
13872            &crate::offload::LocalWorkerResidency {
13873                model_id: "expected/model".into(),
13874                measured_weights_bytes: 2 * 1024 * 1024,
13875            },
13876            backend_cache::BackendRetention::Resident,
13877            &mut reservation,
13878        )
13879        .await
13880        .unwrap();
13881        drop(reservation);
13882        assert_eq!(
13883            *offload.calls.lock().unwrap(),
13884            vec!["allocation:expected/model".to_string()]
13885        );
13886        assert!(coordinator.is_resident("expected/model"));
13887    }
13888
13889    #[async_trait::async_trait]
13890    impl crate::offload::LocalGenerationOffload for FixtureLocalOffload {
13891        async fn generate(
13892            &self,
13893            _request: GenerateRequest,
13894        ) -> Result<InferenceResult, InferenceError> {
13895            unreachable!("streaming fixture")
13896        }
13897
13898        async fn stream(
13899            &self,
13900            _request: GenerateRequest,
13901        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13902            unreachable!("admission-aware streaming fixture")
13903        }
13904
13905        async fn stream_admitted(
13906            &self,
13907            request: GenerateRequest,
13908            admission: crate::offload::LocalWorkerAdmission,
13909        ) -> Result<crate::offload::LocalOffloadStream, InferenceError> {
13910            let (tx, rx) = tokio::sync::mpsc::channel(4);
13911            let emit_done = self.emit_done;
13912            tokio::spawn(async move {
13913                let _ = tx.send(StreamEvent::TextDelta("local answer".into())).await;
13914                if emit_done {
13915                    let _ = tx
13916                        .send(StreamEvent::Done {
13917                            text: "local answer".into(),
13918                            tool_calls: vec![],
13919                        })
13920                        .await;
13921                }
13922            });
13923            Ok(crate::offload::LocalOffloadStream {
13924                events: rx,
13925                residency: crate::offload::LocalWorkerResidency {
13926                    model_id: request.model.unwrap_or_else(|| "fixture/local".into()),
13927                    measured_weights_bytes: admission.measured_weights_bytes,
13928                },
13929                retention: backend_cache::BackendRetention::Resident,
13930            })
13931        }
13932    }
13933
13934    /// Pin the engine's local-admission capacity and live-memory probe to fixed,
13935    /// ample values. Tests that stream through a LOCAL fixture model exercise
13936    /// stream/outcome mechanics, not host admission. The real live probe varies
13937    /// under concurrent builds, while an 8 GB CI host gives Everyday a 3,175 MB
13938    /// ceiling below the fixture's honest 3,275 MB peak (650 weights + 1,024 CPU
13939    /// runtime + 577 context + 1,024 transient). A 16 GB floor and ample live
13940    /// memory keep both gates deterministic without weakening production policy.
13941    fn pin_test_local_admission_memory(engine: &mut InferenceEngine) {
13942        let policy = engine.local_admission.policy();
13943        let mut hardware = HardwareInfo::detect();
13944        hardware.total_ram_mb = hardware.total_ram_mb.max(16 * 1024);
13945        hardware.max_model_mb = hardware.max_model_mb.max(16 * 1024);
13946        engine.local_admission = Arc::new(resource_policy::LocalAdmissionCoordinator::with_probe(
13947            policy,
13948            hardware,
13949            Arc::new(resource_policy::FixedLiveMemoryProbe::known(1_048_576)),
13950        ));
13951    }
13952
13953    fn install_small_local_fixture(engine: &InferenceEngine) -> String {
13954        let schema = engine
13955            .unified_registry
13956            .find_by_name("Qwen3-0.6B")
13957            .expect("small built-in local model")
13958            .clone();
13959        let model_dir = engine.config.models_dir.join(&schema.name);
13960        std::fs::create_dir_all(&model_dir).unwrap();
13961        std::fs::write(model_dir.join("model.gguf"), b"fixture").unwrap();
13962        std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
13963        schema.id
13964    }
13965
13966    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
13967    fn install_exact_pin_equivalent_fixture(engine: &InferenceEngine) -> String {
13968        let gguf = engine
13969            .unified_registry
13970            .get("qwen/qwen3-0.6b:q8_0")
13971            .expect("GGUF fixture row")
13972            .clone();
13973        let mlx = engine
13974            .unified_registry
13975            .get("mlx/qwen3-0.6b:6bit")
13976            .expect("MLX equivalent fixture row")
13977            .clone();
13978
13979        let gguf_dir = engine.config.models_dir.join(&gguf.name);
13980        std::fs::create_dir_all(&gguf_dir).unwrap();
13981        std::fs::write(gguf_dir.join("model.gguf"), b"fixture").unwrap();
13982        std::fs::write(gguf_dir.join("tokenizer.json"), b"{}").unwrap();
13983
13984        let mlx_dir = engine.config.models_dir.join(&mlx.name);
13985        std::fs::create_dir_all(&mlx_dir).unwrap();
13986        std::fs::write(mlx_dir.join("config.json"), b"{}").unwrap();
13987        std::fs::write(mlx_dir.join("model.safetensors"), b"fixture").unwrap();
13988
13989        gguf.id
13990    }
13991
13992    fn remote_stream_fixture_schema(
13993        id: &str,
13994        endpoint: String,
13995        protocol: schema::ApiProtocol,
13996        api_key_env: &str,
13997    ) -> ModelSchema {
13998        ModelSchema {
13999            id: id.into(),
14000            name: "gemini-test".into(),
14001            provider: "test".into(),
14002            family: "test".into(),
14003            version: "1".into(),
14004            capabilities: vec![ModelCapability::Generate],
14005            context_length: 128_000,
14006            max_output_tokens: Some(8_192),
14007            param_count: String::new(),
14008            quantization: None,
14009            performance: Default::default(),
14010            cost: Default::default(),
14011            source: ModelSource::RemoteApi {
14012                endpoint,
14013                api_key_env: api_key_env.into(),
14014                api_key_envs: vec![],
14015                api_version: None,
14016                protocol,
14017            },
14018            tags: vec!["test".into()],
14019            supported_params: vec![],
14020            public_benchmarks: vec![],
14021            trust_tier: TrustTier::Community,
14022            deprecated: false,
14023            available: true,
14024            weights_ready: true,
14025        }
14026    }
14027
14028    async fn assert_remote_model_identity_contract(
14029        protocol: schema::ApiProtocol,
14030        model_id: &str,
14031        provider_model: &str,
14032        api_key_env: &str,
14033    ) {
14034        use wiremock::matchers::{method, path};
14035        use wiremock::{Mock, MockServer, ResponseTemplate};
14036
14037        let server = MockServer::start().await;
14038        let (endpoint_path, response) = match protocol {
14039            schema::ApiProtocol::OpenAiCompat => (
14040                "/v1/chat/completions",
14041                serde_json::json!({
14042                    "choices": [{
14043                        "message": {"content": "openai exact"},
14044                        "finish_reason": "stop"
14045                    }],
14046                    "usage": {"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4}
14047                }),
14048            ),
14049            schema::ApiProtocol::Anthropic => (
14050                "/v1/messages",
14051                serde_json::json!({
14052                    "content": [{"type": "text", "text": "anthropic exact"}],
14053                    "stop_reason": "end_turn",
14054                    "usage": {"input_tokens": 2, "output_tokens": 2}
14055                }),
14056            ),
14057            _ => unreachable!("identity regression covers the newsroom's two remote providers"),
14058        };
14059        Mock::given(method("POST"))
14060            .and(path(endpoint_path))
14061            .respond_with(ResponseTemplate::new(200).set_body_json(response))
14062            .mount(&server)
14063            .await;
14064        unsafe { std::env::set_var(api_key_env, "fixture") };
14065
14066        let tmp = TempDir::new().unwrap();
14067        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
14068        let mut schema =
14069            remote_stream_fixture_schema(model_id, server.uri(), protocol, api_key_env);
14070        schema.name = provider_model.into();
14071        engine.register_model(schema);
14072
14073        let mut exact_request = GenerateRequest {
14074            prompt: "return the exact identity".into(),
14075            ..Default::default()
14076        };
14077        pin_exact_model_id(&mut exact_request, model_id.into()).unwrap();
14078        let exact = engine.generate_tracked(exact_request).await.unwrap();
14079        assert_eq!(exact.model_used, model_id);
14080        assert_eq!(
14081            exact.model_identity.requested_model_id.as_deref(),
14082            Some(model_id)
14083        );
14084        assert_eq!(exact.model_identity.resolved_model_id, model_id);
14085        let exact_envelope = serde_json::to_value(&exact).unwrap();
14086        assert_eq!(exact_envelope["model_used"], model_id);
14087        assert_eq!(exact_envelope["requested_model_id"], model_id);
14088        assert_eq!(exact_envelope["resolved_model_id"], model_id);
14089        assert!(exact_envelope["row_digest"].is_string());
14090        assert!(exact_envelope["catalog_revision"].is_string());
14091
14092        let loose = engine
14093            .generate_tracked(GenerateRequest {
14094                prompt: "keep legacy display-name routing".into(),
14095                model: Some(provider_model.into()),
14096                params: GenerateParams {
14097                    strict_model: true,
14098                    ..Default::default()
14099                },
14100                ..Default::default()
14101            })
14102            .await
14103            .unwrap();
14104        assert_eq!(loose.model_used, provider_model);
14105        assert_eq!(loose.model_identity.requested_model_id, None);
14106        assert_eq!(loose.model_identity.resolved_model_id, model_id);
14107
14108        let requests = server.received_requests().await.unwrap();
14109        assert_eq!(requests.len(), 2);
14110        for request in requests {
14111            let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
14112            assert_eq!(body["model"], provider_model);
14113        }
14114
14115        unsafe { std::env::remove_var(api_key_env) };
14116    }
14117
14118    /// The Metal device lock (`mlx_device_lock`) must be a process-wide singleton
14119    /// AND grant only one holder at a time — that is what serializes every local
14120    /// MLX path (coder generate, streaming, embedding/consolidation) onto the one
14121    /// Metal device so a background consolidation embed can't run concurrently
14122    /// with a coder generate and wedge the device (the daemon "wedge" this fixes).
14123    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
14124    #[tokio::test]
14125    async fn mlx_device_lock_is_singleton_and_serializes() {
14126        use std::sync::atomic::{AtomicUsize, Ordering};
14127        use std::sync::Arc;
14128        // Same underlying mutex across calls (so all MLX paths share one permit).
14129        assert!(
14130            Arc::ptr_eq(
14131                &InferenceEngine::mlx_device_lock(),
14132                &InferenceEngine::mlx_device_lock()
14133            ),
14134            "device lock must be a process-wide singleton"
14135        );
14136        // Mutual exclusion: never more than one holder concurrently.
14137        let inside = Arc::new(AtomicUsize::new(0));
14138        let peak = Arc::new(AtomicUsize::new(0));
14139        let mut handles = Vec::new();
14140        for _ in 0..8 {
14141            let inside = inside.clone();
14142            let peak = peak.clone();
14143            handles.push(tokio::spawn(async move {
14144                let _g = InferenceEngine::mlx_device_lock().lock_owned().await;
14145                let n = inside.fetch_add(1, Ordering::SeqCst) + 1;
14146                peak.fetch_max(n, Ordering::SeqCst);
14147                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
14148                inside.fetch_sub(1, Ordering::SeqCst);
14149            }));
14150        }
14151        for h in handles {
14152            h.await.unwrap();
14153        }
14154        assert_eq!(
14155            peak.load(Ordering::SeqCst),
14156            1,
14157            "at most one MLX device holder at a time"
14158        );
14159    }
14160
14161    /// The F1 auto-thinking gate decision: a coding intent gets "high" (24000),
14162    /// a general Complex turn gets "medium" (8000), and thinking is skipped
14163    /// entirely when the model can't do it — so a non-thinking model never gets
14164    /// a budget that would 400.
14165    #[test]
14166    fn auto_thinking_budget_gates_code_complex_and_capability() {
14167        // Coding intent on a thinking-capable model -> high.
14168        assert_eq!(auto_thinking_budget(true, false, true), Some(24_000));
14169        // General Complex on a thinking-capable model -> medium.
14170        assert_eq!(auto_thinking_budget(false, true, true), Some(8_000));
14171        // Code takes precedence over Complex.
14172        assert_eq!(auto_thinking_budget(true, true, true), Some(24_000));
14173        // Model can't think -> None even for a coding turn (no budget -> no 400).
14174        assert_eq!(auto_thinking_budget(true, true, false), None);
14175        // Neither coding nor complex -> None (plain turns never auto-think).
14176        assert_eq!(auto_thinking_budget(false, false, true), None);
14177    }
14178
14179    #[test]
14180    fn is_explicit_code_intent_keys_on_caller_intent_not_keyword_classifier() {
14181        use crate::intent::{IntentHint, TaskHint};
14182        // The coder/bench set an explicit Code intent — the gate fires.
14183        let code = IntentHint {
14184            task: Some(TaskHint::Code),
14185            ..Default::default()
14186        };
14187        assert!(is_explicit_code_intent(Some(&code)));
14188        // No caller intent -> NOT code, even if the prompt's keyword-classified
14189        // decision.task would be Code. This is the exact over-provisioning the
14190        // gate avoids: a re-key onto decision.task would light up high-effort
14191        // thinking on any prose containing "fix"/"bug"/"let ".
14192        assert!(!is_explicit_code_intent(None));
14193        // A different explicit task is not code.
14194        let reasoning = IntentHint {
14195            task: Some(TaskHint::Reasoning),
14196            ..Default::default()
14197        };
14198        assert!(!is_explicit_code_intent(Some(&reasoning)));
14199        // Intent present but task unset -> NOT code (matches the no-intent path).
14200        let unset = IntentHint {
14201            task: None,
14202            ..Default::default()
14203        };
14204        assert!(!is_explicit_code_intent(Some(&unset)));
14205    }
14206
14207    #[test]
14208    fn strict_model_suppresses_the_local_last_resort_append() {
14209        // Loose (default) remote-only chain → append a local model (resilience).
14210        assert!(should_append_local_last_resort(false, false));
14211        // Hard pin, remote-only chain → do NOT append: the pinned remote model
14212        // must fail loudly, not silently degrade to a weaker local model.
14213        assert!(!should_append_local_last_resort(false, true));
14214        // A chain that already has a local model never needs the last resort,
14215        // strict or not.
14216        assert!(!should_append_local_last_resort(true, false));
14217        assert!(!should_append_local_last_resort(true, true));
14218    }
14219
14220    #[test]
14221    fn last_resort_fallback_never_returns_an_unrunnable_apple_foundation() {
14222        // Regression: apple-foundation is a builtin that is `is_local()` and
14223        // `ready_without_download == Some(true)` on EVERY platform (there is
14224        // nothing to download), but it only executes on Apple Silicon. The
14225        // last-resort local-fallback append used `ready_without_download` alone,
14226        // so off-Apple it handed back `apple-foundation`; the attempt then failed
14227        // with `model not found: apple-foundation`, and — being the last candidate
14228        // — that error MASKED the real remote failure. (Found on Windows when a
14229        // CRLF-corrupted SSE fixture made the managed primary fail; the surfaced
14230        // error blamed apple-foundation, not the fixture.)
14231        //
14232        // Platform-agnostic invariant: whatever the last resort picks, it must be
14233        // runnable here — on Apple apple-foundation is `available` and stays
14234        // eligible; off-Apple it is excluded. Empty models dir ⇒ off-Apple this is
14235        // simply `None`.
14236        let tmp = TempDir::new().unwrap();
14237        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
14238        if let Some(name) = engine.first_installed_local_model(false) {
14239            let runnable = engine
14240                .unified_registry
14241                .find_by_name(&name)
14242                .map(|s| !s.is_foundation_models() || s.available)
14243                .unwrap_or(false);
14244            assert!(
14245                runnable,
14246                "last-resort fallback returned a non-runnable model: {name}"
14247            );
14248        }
14249    }
14250
14251    #[test]
14252    fn empty_tool_catalog_is_no_tools() {
14253        // `tools: Some(vec![])` must behave exactly like `tools: None`:
14254        // no ToolUse routing requirement, and the FoundationModels
14255        // dispatch takes the structured-output path when a JsonSchema
14256        // response_format is present instead of firing the tool path
14257        // (which would warn about — and drop — the schema constraint
14258        // for zero tools).
14259        let mut req = GenerateRequest {
14260            prompt: "p".into(),
14261            ..Default::default()
14262        };
14263        assert!(!InferenceEngine::request_has_tools(&req));
14264        req.tools = Some(vec![]);
14265        assert!(!InferenceEngine::request_has_tools(&req));
14266        req.tools = Some(vec![serde_json::json!({
14267            "name": "t", "description": "d", "parameters": {"type": "object"}
14268        })]);
14269        assert!(InferenceEngine::request_has_tools(&req));
14270    }
14271
14272    #[test]
14273    fn top_k_keeps_only_k_highest() {
14274        // Probs for 5 tokens; top_k=2 keeps the two largest, renormalized.
14275        let mut probs = vec![0.1, 0.4, 0.2, 0.25, 0.05];
14276        InferenceEngine::apply_top_k_top_p(&mut probs, 2, 1.0);
14277        // 0.4 (idx1) and 0.25 (idx3) survive; others zeroed.
14278        assert!(probs[0] == 0.0 && probs[2] == 0.0 && probs[4] == 0.0);
14279        assert!(probs[1] > 0.0 && probs[3] > 0.0);
14280        let sum: f32 = probs.iter().sum();
14281        assert!((sum - 1.0).abs() < 1e-5, "renormalized to 1.0, got {sum}");
14282    }
14283
14284    #[test]
14285    fn top_k_zero_is_a_noop() {
14286        let mut probs = vec![0.1, 0.4, 0.2, 0.3];
14287        let before = probs.clone();
14288        InferenceEngine::apply_top_k_top_p(&mut probs, 0, 1.0);
14289        assert_eq!(probs, before);
14290    }
14291
14292    #[test]
14293    fn top_p_nucleus_truncates_tail() {
14294        let mut probs = vec![0.6, 0.3, 0.07, 0.03];
14295        InferenceEngine::apply_top_k_top_p(&mut probs, 0, 0.9);
14296        // 0.6 + 0.3 = 0.9 crosses the threshold at the 2nd token; tail zeroed.
14297        assert!(probs[2] == 0.0 && probs[3] == 0.0);
14298        assert!(probs[0] > 0.0 && probs[1] > 0.0);
14299    }
14300
14301    #[test]
14302    fn truncate_at_stop_excludes_stop_sequence() {
14303        let stops = vec!["<|end|>".to_string(), "STOP".to_string()];
14304        assert_eq!(
14305            tasks::generate::truncate_at_stop("hello world<|end|>extra", &stops),
14306            "hello world"
14307        );
14308        // Earliest match wins.
14309        assert_eq!(
14310            tasks::generate::truncate_at_stop("aSTOPb<|end|>c", &stops),
14311            "a"
14312        );
14313        // No match -> unchanged.
14314        assert_eq!(
14315            tasks::generate::truncate_at_stop("clean output", &stops),
14316            "clean output"
14317        );
14318        // Empty stop entries ignored.
14319        assert_eq!(
14320            tasks::generate::truncate_at_stop("text", &["".to_string()]),
14321            "text"
14322        );
14323    }
14324
14325    #[test]
14326    fn no_backend_hint_fires_on_missing_backend_phrases() {
14327        // The four phrases the engine emits when nothing is runnable.
14328        for phrase in [
14329            "no credential for proprietary provider 'parslee'",
14330            "model not found",
14331            "no models available",
14332            "model declares ModelSource::Delegated but no inference runner is registered",
14333        ] {
14334            let hint = no_backend_recovery_hint(phrase)
14335                .unwrap_or_else(|| panic!("expected a hint for {phrase:?}"));
14336            assert!(hint.contains("car models pull"));
14337            // The CLI verb is `car auth login` (no `parslee` positional — that
14338            // was a stale doc-ism the hint used to print).
14339            assert!(hint.contains("car auth login"));
14340            // Underlying error is preserved for diagnosis.
14341            assert!(hint.contains(phrase));
14342        }
14343    }
14344
14345    #[test]
14346    fn no_backend_hint_opens_with_the_exported_marker() {
14347        // Cross-crate consumers (car-server-core's coder classifier) key on
14348        // NO_BACKEND_RECOVERY_MARKER. The hint is BUILT from the constant, so
14349        // this pins the two together rather than re-copying the literal.
14350        let hint = no_backend_recovery_hint(
14351            "model declares ModelSource::Delegated but no inference runner is registered",
14352        )
14353        .expect("a delegated model with no runner is a no-backend case");
14354        assert!(
14355            hint.starts_with(NO_BACKEND_RECOVERY_MARKER),
14356            "hint must open with the exported marker: {hint}"
14357        );
14358        assert!(hint.contains(NO_BACKEND_RECOVERY_MARKER), "{hint}");
14359    }
14360
14361    #[test]
14362    fn route_failure_context_keeps_the_no_backend_marker_behind_the_credential_summary() {
14363        // The augment shape is "{summary}; {hint}" (apply_route_failure_context),
14364        // so the marker survives but no longer STARTS the message. Driven through
14365        // the real helper with the real signed-out route failure.
14366        let credential = parslee_signed_out_route_failure();
14367        let underlying = InferenceError::InferenceFailed(
14368            "model declares ModelSource::Delegated but no inference runner is registered".into(),
14369        );
14370        let augmented = apply_route_failure_context(underlying, Some(&credential));
14371        let InferenceError::InferenceFailed(message) = &augmented else {
14372            panic!("a no-backend exhaustion stays InferenceFailed: {augmented:?}");
14373        };
14374        assert!(
14375            message.starts_with(&credential.summary),
14376            "credential context is prepended: {message}"
14377        );
14378        assert!(
14379            message.contains(NO_BACKEND_RECOVERY_MARKER),
14380            "the marker survives the prepend: {message}"
14381        );
14382        assert!(
14383            !message.starts_with(NO_BACKEND_RECOVERY_MARKER),
14384            "the marker is mid-message here, which is why consumers use contains: {message}"
14385        );
14386    }
14387
14388    /// Parslee-ai/car#797 item 2 — the credential failure is matchable as DATA,
14389    /// not by substring-matching English that can be reworded at any time.
14390    ///
14391    /// The distinction that matters to a consumer: a token that aged out
14392    /// mid-run is a *resumable* condition for anything that can checkpoint,
14393    /// while a signed-out account is a hard stop, and an unreadable keychain is
14394    /// neither (re-authenticating does not help it).
14395    #[test]
14396    fn credential_failure_is_matchable_as_data() {
14397        let expired = InferenceError::CredentialUnavailable {
14398            provider: "parslee".into(),
14399            model: "parslee/reasoning".into(),
14400            reason: CredentialFailure::Expired {
14401                expires_at: 1_754_257_929,
14402            },
14403            detail: "the Parslee token expired at unix 1754257929 and could not be refreshed"
14404                .into(),
14405        };
14406        let InferenceError::CredentialUnavailable { reason, .. } = &expired else {
14407            panic!("expected CredentialUnavailable");
14408        };
14409        assert_eq!(
14410            *reason,
14411            CredentialFailure::Expired {
14412                expires_at: 1_754_257_929
14413            },
14414            "a consumer must be able to branch on the expiry without parsing prose"
14415        );
14416        // The four failure modes are distinct values, because each has a
14417        // different remedy and collapsing any two would send a user to the
14418        // wrong one.
14419        assert_ne!(
14420            CredentialFailure::SignedOut,
14421            CredentialFailure::StoreUnreadable
14422        );
14423        assert_ne!(
14424            CredentialFailure::SignedOut,
14425            CredentialFailure::Expired { expires_at: 0 }
14426        );
14427        assert_ne!(
14428            CredentialFailure::StoreUnreadable,
14429            CredentialFailure::RaceRetryable
14430        );
14431    }
14432
14433    /// The typed variant must keep rendering the historical prefix, because two
14434    /// downstream classifiers substring-match it.
14435    ///
14436    /// `native_loop::is_auth_failure` drives the wait-for-sign-in path, and the
14437    /// coder-ab harness's `INFRA_MARKERS` keeps auth casualties out of a
14438    /// benchmark denominator. Both look for `no credential for proprietary`.
14439    /// Changing the error from a formatted string to a typed variant is exactly
14440    /// the kind of refactor that silently breaks them, so this pins the
14441    /// rendering rather than trusting the `#[error]` attribute to stay put.
14442    #[test]
14443    fn typed_credential_error_still_satisfies_the_substring_classifiers() {
14444        for reason in [
14445            CredentialFailure::Expired { expires_at: 1 },
14446            CredentialFailure::SignedOut,
14447            CredentialFailure::StoreUnreadable,
14448            CredentialFailure::RaceRetryable,
14449            CredentialFailure::EnvVarMissing {
14450                env_var: "OPENAI_API_KEY".into(),
14451            },
14452        ] {
14453            let rendered = InferenceError::CredentialUnavailable {
14454                provider: "parslee".into(),
14455                model: "parslee/reasoning".into(),
14456                reason: reason.clone(),
14457                detail: "detail text".into(),
14458            }
14459            .to_string();
14460            // `native_loop::is_auth_failure` + coder_ab INFRA_MARKERS.
14461            assert!(
14462                rendered
14463                    .to_ascii_lowercase()
14464                    .contains("no credential for proprietary"),
14465                "classifier substring lost for {reason:?}: {rendered}"
14466            );
14467            // The model is named, so a multi-model run can tell which call died.
14468            assert!(rendered.contains("parslee/reasoning"), "{rendered}");
14469            // And the human-facing detail survives.
14470            assert!(rendered.contains("detail text"), "{rendered}");
14471        }
14472    }
14473
14474    #[test]
14475    fn auth_expired_hint_fires_on_auth_rejection_but_not_transient() {
14476        // Auth-rejection exhaustion → actionable re-auth guidance.
14477        for phrase in [
14478            "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
14479            "HTTP 403: forbidden",
14480            "invalid_grant: The refresh token is invalid or expired",
14481            "token expired",
14482        ] {
14483            let hint = auth_expired_recovery_hint(phrase)
14484                .unwrap_or_else(|| panic!("expected an auth hint for {phrase:?}"));
14485            assert!(hint.contains("car auth login"));
14486            assert!(hint.contains(phrase));
14487        }
14488        // A genuine transient (5xx / timeout) must NOT be classified as auth.
14489        assert!(auth_expired_recovery_hint("API returned 503: service unavailable").is_none());
14490        assert!(auth_expired_recovery_hint("request timed out").is_none());
14491    }
14492
14493    /// Guard for a CROSS-CRATE contract. car-cli's `err_is_no_local_model`
14494    /// (car-rs/crates/car-cli/src/main.rs) decides whether `car infer` may
14495    /// auto-pull a multi-gigabyte on-device model, and it says yes on exactly
14496    /// one thing: [`NO_BACKEND_RECOVERY_MARKER`]. This hint deliberately NAMES
14497    /// the same `car models pull` command — as an ALTERNATIVE to signing in
14498    /// again — while proving nothing about whether a local model is installed.
14499    /// The two must therefore never overlap: the moment this hint carries the
14500    /// marker, a lapsed session starts downloading gigabytes that a
14501    /// `car auth login` would have fixed (Parslee-ai/car#1539 follow-up).
14502    ///
14503    /// car-cli cannot call this function, which is private to this crate, so
14504    /// its own negative test
14505    /// `err_is_no_local_model_rejects_the_auth_expired_recovery_hint` works
14506    /// from a COPY of this wording. This test is what keeps that copy honest:
14507    /// it runs the REAL producer, once per phrase `is_auth_rejection_message`
14508    /// keys on, so a drift here fails in this crate instead of silently
14509    /// outdating the CLI's copy.
14510    #[test]
14511    fn auth_expired_hint_never_carries_the_no_backend_marker() {
14512        // One phrase per alternative in `is_auth_rejection_message`, each
14513        // isolated so it exercises that alternative on its own.
14514        for phrase in [
14515            "Parslee org lookup failed: the account service could not be reached",
14516            "Authentication required",
14517            "HTTP 401 Unauthorized",
14518            "HTTP 403 Forbidden",
14519            "invalid_grant: the refresh token is invalid or expired",
14520            "token expired",
14521        ] {
14522            let hint = auth_expired_recovery_hint(phrase)
14523                .unwrap_or_else(|| panic!("expected an auth hint for {phrase:?}"));
14524            assert!(
14525                !hint.contains(NO_BACKEND_RECOVERY_MARKER),
14526                "the auth-expired hint now carries the no-backend marker, so \
14527                 car infer would auto-pull for {phrase:?}: {hint}"
14528            );
14529            // The hint really does name the pull command, so the assertion
14530            // above guards an overlap that could happen rather than a vacuous
14531            // one — this is exactly why the CLI must not key on that command.
14532            assert!(hint.contains("car models pull"), "{hint}");
14533        }
14534        // The hint's other branch: an input that is not an auth rejection
14535        // produces nothing at all, so there is no text to carry a marker.
14536        assert!(auth_expired_recovery_hint("API returned 503: service unavailable").is_none());
14537    }
14538
14539    /// The single public definition of "the credential was rejected". Pinned to
14540    /// the LITERAL error an expired Parslee session produces (Parslee-ai/car#888):
14541    /// the coder loop's own matcher missed this exact string, so an expired
14542    /// token burned inference strikes instead of asking for a sign-in.
14543    #[test]
14544    fn auth_rejection_classifier_matches_the_real_expired_token_error() {
14545        assert!(is_auth_rejection_message(
14546            "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
14547             Authentication required"
14548        ));
14549        // A transient is NOT a sign-in problem — telling an operator to
14550        // re-authenticate through a 503 sends them to fix what isn't broken.
14551        for transient in [
14552            "API returned 503: service unavailable",
14553            "request timed out",
14554            "connection reset by peer",
14555        ] {
14556            assert!(
14557                !is_auth_rejection_message(transient),
14558                "transient wrongly classified as an auth rejection: {transient:?}"
14559            );
14560        }
14561    }
14562
14563    /// Fixtures built from the real `InferenceError` values these paths
14564    /// produce, not invented to match the arms.
14565    ///
14566    /// The first version of this test hand-wrote eleven plausible strings and
14567    /// passed, while `ProviderAccount`'s actual Display — the one a refused API
14568    /// key produces — classified as `Failed`. A test whose vocabulary comes
14569    /// from the implementation agrees with it by construction.
14570    #[test]
14571    fn typed_errors_classify_by_their_structure_not_their_prose() {
14572        use FallbackReason as R;
14573        let cases: Vec<(InferenceError, R)> = vec![
14574            // The single most actionable degrade there is. Its Display carries
14575            // no "unauthorized" token, which is what the string rule missed.
14576            (
14577                InferenceError::ProviderAccount {
14578                    provider: "openai".into(),
14579                    status: 401,
14580                    message: "provider rejected the API key — check the configured credential"
14581                        .into(),
14582                },
14583                R::CredentialRejected,
14584            ),
14585            // Out of credits is a billing wait, not a wrong credential.
14586            (
14587                InferenceError::ProviderAccount {
14588                    provider: "openrouter".into(),
14589                    status: 402,
14590                    message: "OpenRouter account is out of credits".into(),
14591                },
14592                // NOT RateLimited: an empty balance does not clear by waiting.
14593                R::QuotaExhausted,
14594            ),
14595            (
14596                InferenceError::CredentialUnavailable {
14597                    provider: "parslee".into(),
14598                    model: "parslee/reasoning".into(),
14599                    reason: CredentialFailure::Expired { expires_at: 0 },
14600                    detail: "session expired".into(),
14601                },
14602                R::CredentialRejected,
14603            ),
14604            (
14605                InferenceError::CredentialUnavailable {
14606                    provider: "openai".into(),
14607                    model: "openai/gpt-5.6".into(),
14608                    reason: CredentialFailure::EnvVarMissing {
14609                        env_var: "OPENAI_API_KEY".into(),
14610                    },
14611                    detail: "not set".into(),
14612                },
14613                R::CredentialAbsent,
14614            ),
14615            // A locked keychain says NOTHING about whether a credential
14616            // exists, so neither absent nor rejected is honest.
14617            (
14618                InferenceError::CredentialUnavailable {
14619                    provider: "parslee".into(),
14620                    model: "parslee/reasoning".into(),
14621                    reason: CredentialFailure::StoreUnreadable,
14622                    detail: "the bounded Keychain helper timed out".into(),
14623                },
14624                R::Failed,
14625            ),
14626            (
14627                InferenceError::Transient {
14628                    status: Some(429),
14629                    message: "slow down".into(),
14630                },
14631                R::RateLimited,
14632            ),
14633            // A statusless transport error is NOT a timeout: the same variant
14634            // carries connection-refused, DNS and TLS failures, and telling
14635            // someone their call timed out when the endpoint was never up
14636            // sends them to raise a timeout instead of starting the runtime.
14637            (
14638                InferenceError::Transient {
14639                    status: None,
14640                    message: "connection reset by peer".into(),
14641                },
14642                R::Failed,
14643            ),
14644            (
14645                InferenceError::Transient {
14646                    status: Some(503),
14647                    message: "upstream down".into(),
14648                },
14649                R::Failed,
14650            ),
14651        ];
14652        for (err, want) in cases {
14653            assert_eq!(classify_fallback_reason(&err), want, "{err}");
14654        }
14655    }
14656
14657    /// A provider's error body is text we did not write. It can quote any
14658    /// status or phrase, and the status line is the only part we control.
14659    ///
14660    /// Both cases below are lifted from defects this workspace already fixed
14661    /// elsewhere: `remote::is_auth_rejection` has a test pinning the 400-that-
14662    /// quotes-401, and `is_provider_transient` anchors on the parsed status
14663    /// because of the 400-whose-message-says-timeout.
14664    #[test]
14665    fn a_quoted_status_in_a_provider_body_does_not_decide_the_bucket() {
14666        use FallbackReason as R;
14667        for (msg, want) in [
14668            (
14669                "API returned 400 Bad Request: your last request 401'd upstream and was unauthorized",
14670                R::Failed,
14671            ),
14672            ("API returned 400 Bad Request: timeout param invalid", R::Failed),
14673            ("API returned 429 Too Many Requests: slow down", R::RateLimited),
14674            ("API returned 401 Unauthorized: bad key", R::CredentialRejected),
14675        ] {
14676            assert_eq!(
14677                classify_fallback_reason(&InferenceError::InferenceFailed(msg.into())),
14678                want,
14679                "{msg}"
14680            );
14681        }
14682    }
14683
14684    /// `Parslee org lookup failed: HTTP <status>` is emitted for ANY non-success
14685    /// status, so the phrase alone is not a dead credential — the producer
14686    /// itself gates `note_credential_rejected()` on 401/403 for this reason.
14687    #[test]
14688    fn a_parslee_org_lookup_failure_is_classified_by_its_status() {
14689        use FallbackReason as R;
14690        for (msg, want) in [
14691            (
14692                "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
14693                R::CredentialRejected,
14694            ),
14695            (
14696                "Parslee org lookup failed: HTTP 429 Too Many Requests: slow down",
14697                R::RateLimited,
14698            ),
14699            (
14700                "Parslee org lookup failed: HTTP 500 Internal Server Error: boom",
14701                R::Failed,
14702            ),
14703        ] {
14704            assert_eq!(
14705                classify_fallback_reason(&InferenceError::InferenceFailed(msg.into())),
14706                want,
14707                "{msg}"
14708            );
14709        }
14710    }
14711
14712    /// EVERY hop, in order — a chain that skips three lanes made three
14713    /// transitions, and a single first-wins slot records one while the journal
14714    /// downstream claims to hold them all.
14715    #[test]
14716    fn every_skipped_lane_is_recorded_in_order() {
14717        let mut hops = Vec::new();
14718        for (cand, err) in [
14719            (
14720                "lane-one",
14721                InferenceError::Transient {
14722                    status: Some(429),
14723                    message: "x".into(),
14724                },
14725            ),
14726            // Statusless transport: `Failed`, not `TimedOut` — the runtime
14727            // cannot tell a refused connection from a real deadline here.
14728            (
14729                "lane-two",
14730                InferenceError::Transient {
14731                    status: None,
14732                    message: "connection refused".into(),
14733                },
14734            ),
14735            (
14736                "lane-three",
14737                InferenceError::ProviderAccount {
14738                    provider: "openai".into(),
14739                    status: 401,
14740                    message: "rejected".into(),
14741                },
14742            ),
14743        ] {
14744            record_fallback_from(&mut hops, cand, &err);
14745        }
14746        assert_eq!(
14747            hops.iter()
14748                .map(|h| h.candidate.as_str())
14749                .collect::<Vec<_>>(),
14750            ["lane-one", "lane-two", "lane-three"]
14751        );
14752        assert_eq!(
14753            hops.iter().map(|h| h.reason).collect::<Vec<_>>(),
14754            [
14755                FallbackReason::RateLimited,
14756                FallbackReason::Failed,
14757                FallbackReason::CredentialRejected
14758            ]
14759        );
14760    }
14761
14762    /// `CredentialRejected` is BROADER than `auth_fallback_from`'s predicate,
14763    /// which is why the two are recorded independently rather than one being
14764    /// projected from the other.
14765    ///
14766    /// A provider refusing an API key is a rejected credential, and the journal
14767    /// should say so. It is NOT something `car auth login` fixes, so it must
14768    /// not drive the announcement that says to run it.
14769    #[test]
14770    fn a_refused_api_key_is_journaled_but_does_not_claim_sign_in_fixes_it() {
14771        let refused = InferenceError::ProviderAccount {
14772            provider: "openai".into(),
14773            status: 401,
14774            message: "provider rejected the API key — check the configured credential".into(),
14775        };
14776        let mut hops = Vec::new();
14777        record_fallback_from(&mut hops, "openai/gpt-5.6", &refused);
14778        assert_eq!(hops[0].reason, FallbackReason::CredentialRejected);
14779
14780        // The sign-in slot stays empty: this is not a lapsed session.
14781        let mut auth = None;
14782        record_auth_dead_lane(&mut auth, "openai/gpt-5.6", &refused.to_string());
14783        assert_eq!(auth, None, "car auth login does not fix a bad OpenAI key");
14784    }
14785
14786    #[test]
14787    fn auth_dead_lane_records_first_rejected_candidate_only() {
14788        // Nothing auth-failed → the field stays None, which is the common path.
14789        let mut slot: Option<String> = None;
14790        record_auth_dead_lane(
14791            &mut slot,
14792            "parslee/reasoning",
14793            "API returned 503: unavailable",
14794        );
14795        record_auth_dead_lane(&mut slot, "openai/gpt-5.6", "request timed out");
14796        assert_eq!(slot, None);
14797
14798        // An auth rejection names the lane...
14799        record_auth_dead_lane(
14800            &mut slot,
14801            "parslee/reasoning",
14802            "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
14803        );
14804        assert_eq!(slot.as_deref(), Some("parslee/reasoning"));
14805
14806        // ...and a LATER rejection does not overwrite it: the first one is the
14807        // lane the operator configured.
14808        record_auth_dead_lane(&mut slot, "anthropic/claude", "HTTP 403: forbidden");
14809        assert_eq!(slot.as_deref(), Some("parslee/reasoning"));
14810    }
14811
14812    #[test]
14813    fn configured_provider_with_expired_token_is_named_before_a_local_oom() {
14814        let expired = InferenceError::CredentialUnavailable {
14815            provider: "parslee".into(),
14816            model: "parslee/reasoning".into(),
14817            reason: CredentialFailure::Expired { expires_at: 42 },
14818            detail: "access token expired".into(),
14819        };
14820        let mut credential = None;
14821        record_route_credential_failure(&mut credential, "parslee/reasoning", &expired, false);
14822
14823        let error = apply_route_failure_context(
14824            InferenceError::InferenceFailed(
14825                "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
14826                    .into(),
14827            ),
14828            credential.as_ref(),
14829        )
14830        .to_string();
14831
14832        let auth_pos = error
14833            .find("Parslee login expired")
14834            .expect("expired login must be named");
14835        let remedy_pos = error
14836            .find("car auth login")
14837            .expect("credential remedy must be named");
14838        let oom_pos = error
14839            .find("9059 MB")
14840            .expect("fallback error must remain as secondary detail");
14841        assert!(auth_pos < remedy_pos && remedy_pos < oom_pos, "{error}");
14842        assert!(is_auth_failure_message(&error), "{error}");
14843    }
14844
14845    #[test]
14846    fn absent_login_is_named_before_a_local_oom() {
14847        let failure = parslee_signed_out_route_failure();
14848        let error = apply_route_failure_context(
14849            InferenceError::InferenceFailed("local fallback needs 9059 MB".into()),
14850            Some(&failure),
14851        )
14852        .to_string();
14853
14854        let absent_pos = error
14855            .find("Parslee login is absent")
14856            .expect("missing login must be named");
14857        let remedy_pos = error
14858            .find("car auth login")
14859            .expect("credential remedy must be named");
14860        let oom_pos = error
14861            .find("9059 MB")
14862            .expect("fallback OOM must remain as secondary detail");
14863        assert!(absent_pos < remedy_pos && remedy_pos < oom_pos, "{error}");
14864    }
14865
14866    #[test]
14867    fn genuine_local_oom_is_not_reclassified_as_auth() {
14868        let oom = InferenceError::InferenceFailed(
14869            "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
14870                .into(),
14871        );
14872        let error = apply_route_failure_context(oom, None);
14873        assert!(matches!(error, InferenceError::InferenceFailed(ref message)
14874            if message.starts_with("This model needs about 9059 MB")));
14875    }
14876
14877    #[test]
14878    fn unconfigured_provider_is_not_surfaced_over_the_terminal_failure() {
14879        let mut credential = None;
14880        record_route_credential_failure(
14881            &mut credential,
14882            "openai/gpt-5.6",
14883            &InferenceError::CredentialUnavailable {
14884                provider: "openai".into(),
14885                model: "openai/gpt-5.6".into(),
14886                reason: CredentialFailure::EnvVarMissing {
14887                    env_var: "OPENAI_API_KEY".into(),
14888                },
14889                detail: "set OPENAI_API_KEY".into(),
14890            },
14891            false,
14892        );
14893
14894        let terminal = InferenceError::InferenceFailed(
14895            "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
14896                .into(),
14897        );
14898        let error = apply_route_failure_context(terminal, credential.as_ref()).to_string();
14899        assert!(
14900            credential.is_none(),
14901            "an unconfigured fallback is ambient noise"
14902        );
14903        assert!(error.contains("9059 MB"), "{error}");
14904        assert!(!error.contains("OPENAI_API_KEY"), "{error}");
14905        assert!(!is_auth_failure_message(&error), "{error}");
14906    }
14907
14908    /// The exact error `parslee_identity` produces, built here so a producer
14909    /// reword shows up in these tests rather than in a signed-in person's
14910    /// first chat turn.
14911    fn workspace_required() -> InferenceError {
14912        InferenceError::WorkspaceRequired {
14913            provider: "Parslee".into(),
14914            detail: "finish setting up at https://parslee.ai, then try again".into(),
14915        }
14916    }
14917
14918    /// The load-bearing property of `WorkspaceRequired`: it must read as
14919    /// CONFIGURATION, never as a credential failure.
14920    ///
14921    /// The person IS signed in. If this text matched the shared auth table the
14922    /// coder would park an unattended build on a sign-in wait that cannot
14923    /// resolve, and the out-of-the-box agent would offer a sign-in button that
14924    /// leads straight back here. Asserted against the real table, entry by
14925    /// entry, so adding a marker that happens to collide fails here.
14926    #[test]
14927    fn workspace_required_reads_as_configuration_not_sign_in() {
14928        let rendered = workspace_required().to_string();
14929        for marker in AUTH_FAILURE_MESSAGE_MARKERS {
14930            assert!(
14931                !rendered
14932                    .to_ascii_lowercase()
14933                    .contains(&marker.to_ascii_lowercase()),
14934                "`{marker}` must not appear in the no-workspace text: {rendered}"
14935            );
14936        }
14937        assert!(!is_auth_failure_message(&rendered), "{rendered}");
14938        assert!(!is_auth_rejection_message(&rendered), "{rendered}");
14939        assert!(rendered.contains("https://parslee.ai"), "{rendered}");
14940    }
14941
14942    /// A mixed chain is the ordinary case, not a corner: an earlier candidate
14943    /// records a credential failure, Parslee then fails on the workspace, and
14944    /// every exhausted chain's final error passes through
14945    /// `apply_route_failure_context`. Without the early return the wildcard at
14946    /// the bottom of that match re-renders the variant as `InferenceFailed`
14947    /// and the `no_workspace` refusal loses the type it is built on.
14948    #[test]
14949    fn workspace_required_survives_a_mixed_chain() {
14950        let mut credential = None;
14951        record_route_credential_failure(
14952            &mut credential,
14953            "openai/gpt-5.6",
14954            &InferenceError::CredentialUnavailable {
14955                provider: "openai".into(),
14956                model: "openai/gpt-5.6".into(),
14957                reason: CredentialFailure::SignedOut,
14958                detail: "no account is signed in".into(),
14959            },
14960            true,
14961        );
14962        assert!(
14963            credential.is_some(),
14964            "the fixture must actually record a credential failure"
14965        );
14966
14967        let final_error = apply_route_failure_context(workspace_required(), credential.as_ref());
14968        assert!(
14969            matches!(final_error, InferenceError::WorkspaceRequired { .. }),
14970            "the type must survive the route context: {final_error}"
14971        );
14972        assert!(!is_auth_failure_message(&final_error.to_string()));
14973        assert!(
14974            error_ends_fallback_chain(&final_error),
14975            "no later candidate on that account can succeed"
14976        );
14977    }
14978
14979    /// Model health must not learn anything from a workspace gap. Every model
14980    /// on that account fails identically and none of them was given a chance,
14981    /// so benching them would outlive the one web step that fixes it.
14982    /// The control in the same test proves the tracker does record ordinary
14983    /// failures — otherwise this would pass against a tracker that counts
14984    /// nothing at all.
14985    #[test]
14986    fn repeated_workspace_required_leaves_model_health_untouched() {
14987        assert!(
14988            !error_counts_against_circuit_breaker(&workspace_required()),
14989            "the breaker must not count a workspace gap"
14990        );
14991
14992        let mut tracker = OutcomeTracker::new();
14993        for _ in 0..5 {
14994            let trace = tracker.record_start("parslee/advisor", InferenceTask::Generate, "test");
14995            record_dispatch_failure(&mut tracker, &trace, &workspace_required());
14996        }
14997        let failed = tracker
14998            .profile("parslee/advisor")
14999            .map(|p| p.fail_count)
15000            .unwrap_or(0);
15001        assert_eq!(
15002            failed, 0,
15003            "five workspace refusals must not bench the model"
15004        );
15005
15006        let trace = tracker.record_start("parslee/advisor", InferenceTask::Generate, "test");
15007        record_dispatch_failure(
15008            &mut tracker,
15009            &trace,
15010            &InferenceError::InferenceFailed("decoder fell over".into()),
15011        );
15012        assert_eq!(
15013            tracker
15014                .profile("parslee/advisor")
15015                .map(|p| p.fail_count)
15016                .unwrap_or(0),
15017            1,
15018            "a genuine failure must still count, or the test above proves nothing"
15019        );
15020    }
15021
15022    #[test]
15023    fn explicitly_requested_missing_credential_uses_the_shared_auth_table() {
15024        let mut credential = None;
15025        record_route_credential_failure(
15026            &mut credential,
15027            "openai/gpt-5.6",
15028            &InferenceError::CredentialUnavailable {
15029                provider: "openai".into(),
15030                model: "openai/gpt-5.6".into(),
15031                reason: CredentialFailure::EnvVarMissing {
15032                    env_var: "OPENAI_API_KEY".into(),
15033                },
15034                detail: "set OPENAI_API_KEY".into(),
15035            },
15036            true,
15037        );
15038        let summary = credential
15039            .expect("an explicitly requested provider must surface its missing key")
15040            .summary;
15041        assert!(summary.contains(AUTH_ENV_MISSING_MARKER), "{summary}");
15042        assert!(is_auth_failure_message(&summary), "{summary}");
15043    }
15044
15045    #[test]
15046    fn latest_actionable_credential_failure_wins() {
15047        let mut credential = None;
15048        record_route_credential_failure(
15049            &mut credential,
15050            "parslee/reasoning",
15051            &InferenceError::ProviderAccount {
15052                provider: "parslee".into(),
15053                status: 401,
15054                message: "Unauthorized".into(),
15055            },
15056            false,
15057        );
15058        record_route_credential_failure(
15059            &mut credential,
15060            "anthropic/claude",
15061            &InferenceError::CredentialUnavailable {
15062                provider: "anthropic".into(),
15063                model: "anthropic/claude".into(),
15064                reason: CredentialFailure::Expired { expires_at: 43 },
15065                detail: "configured token expired".into(),
15066            },
15067            false,
15068        );
15069
15070        assert_eq!(
15071            credential
15072                .expect("latest actionable credential cause must be retained")
15073                .summary,
15074            "anthropic login expired for `anthropic/claude` — run `car auth login`"
15075        );
15076    }
15077
15078    #[test]
15079    fn store_unreadable_summary_uses_the_shared_auth_table() {
15080        let summary = route_credential_failure(
15081            "parslee/reasoning",
15082            &InferenceError::CredentialUnavailable {
15083                provider: "parslee".into(),
15084                model: "parslee/reasoning".into(),
15085                reason: CredentialFailure::StoreUnreadable,
15086                detail: "keychain helper timed out".into(),
15087            },
15088            false,
15089        )
15090        .expect("an unreadable configured credential store is actionable");
15091        assert!(summary.contains(AUTH_STORE_UNREADABLE_MARKER), "{summary}");
15092        assert!(is_auth_failure_message(&summary), "{summary}");
15093    }
15094
15095    fn chain_gate_fixture_schema(id: &str, provider: &str, source: ModelSource) -> ModelSchema {
15096        ModelSchema {
15097            id: id.into(),
15098            name: id.into(),
15099            provider: provider.into(),
15100            family: "test".into(),
15101            version: "1".into(),
15102            capabilities: vec![ModelCapability::Generate],
15103            context_length: 32_768,
15104            max_output_tokens: Some(4_096),
15105            param_count: String::new(),
15106            quantization: None,
15107            performance: Default::default(),
15108            cost: Default::default(),
15109            source,
15110            tags: vec![],
15111            supported_params: vec![],
15112            public_benchmarks: vec![],
15113            trust_tier: TrustTier::Community,
15114            deprecated: false,
15115            available: true,
15116            weights_ready: true,
15117        }
15118    }
15119
15120    #[test]
15121    fn signed_out_pre_seed_is_gated_on_a_parslee_route_in_the_chain() {
15122        let local = chain_gate_fixture_schema(
15123            "qwen/qwen3-4b:q4_k_m",
15124            "qwen",
15125            ModelSource::Mlx {
15126                hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
15127                hf_weight_file: None,
15128            },
15129        );
15130        let ollama = chain_gate_fixture_schema(
15131            "ollama/llama3",
15132            "ollama",
15133            ModelSource::Ollama {
15134                model_tag: "llama3".into(),
15135                host: "http://localhost:11434".into(),
15136            },
15137        );
15138        let parslee = chain_gate_fixture_schema(
15139            "parslee/reasoning",
15140            "parslee",
15141            ModelSource::Proprietary {
15142                provider: "parslee".into(),
15143                endpoint: "https://api.parslee.ai".into(),
15144                auth: ProprietaryAuth::OAuth2Pkce {
15145                    authority: "https://login.example".into(),
15146                    client_id: "client".into(),
15147                    scopes: vec![],
15148                },
15149                protocol: Default::default(),
15150            },
15151        );
15152        let cloud = chain_gate_fixture_schema(
15153            "openai/gpt-5.6",
15154            "openai",
15155            ModelSource::RemoteApi {
15156                endpoint: "https://api.openai.com".into(),
15157                api_key_env: "OPENAI_API_KEY".into(),
15158                api_key_envs: vec![],
15159                api_version: None,
15160                protocol: schema::ApiProtocol::OpenAiCompat,
15161            },
15162        );
15163        let schemas: std::collections::HashMap<&str, &ModelSchema> = [
15164            ("qwen/qwen3-4b:q4_k_m", &local),
15165            ("ollama/llama3", &ollama),
15166            ("parslee/reasoning", &parslee),
15167            ("openai/gpt-5.6", &cloud),
15168        ]
15169        .into_iter()
15170        .collect();
15171        let resolve = |m: &str| schemas.get(m).copied();
15172
15173        // Local weights and local servers resolve no credential: no pre-seed.
15174        let local_only = vec![
15175            "qwen/qwen3-4b:q4_k_m".to_string(),
15176            "ollama/llama3".to_string(),
15177        ];
15178        assert!(!chain_includes_parslee_route(resolve, &local_only));
15179
15180        // Only a Parslee route keeps the Parslee-specific snapshot failure.
15181        let with_parslee = vec![
15182            "parslee/reasoning".to_string(),
15183            "qwen/qwen3-4b:q4_k_m".to_string(),
15184        ];
15185        assert!(chain_includes_parslee_route(resolve, &with_parslee));
15186        let with_cloud = vec![
15187            "openai/gpt-5.6".to_string(),
15188            "qwen/qwen3-4b:q4_k_m".to_string(),
15189        ];
15190        // An adaptive request with no explicit model can select a configured
15191        // preferred OpenAI route, then append a local fallback. Neither uses
15192        // the missing Parslee credential from the routing snapshot.
15193        assert!(!chain_includes_parslee_route(resolve, &with_cloud));
15194        let mut credential = chain_includes_parslee_route(resolve, &with_cloud)
15195            .then(parslee_signed_out_route_failure);
15196        let outage = InferenceError::Transient {
15197            status: Some(500),
15198            message: "OpenAI HTTP 500 Internal Server Error".into(),
15199        };
15200        record_route_credential_failure(&mut credential, &with_cloud[0], &outage, false);
15201        let terminal = apply_route_failure_context(outage, credential.as_ref());
15202        assert!(matches!(terminal, InferenceError::Transient { .. }));
15203        assert!(!is_auth_failure_message(&terminal.to_string()));
15204        let oom = InferenceError::InferenceFailed("local model out of memory".into());
15205        record_route_credential_failure(&mut credential, &with_cloud[1], &oom, false);
15206        let terminal = apply_route_failure_context(oom, credential.as_ref()).to_string();
15207        assert!(!terminal.contains("Parslee"), "{terminal}");
15208        assert!(!is_auth_failure_message(&terminal), "{terminal}");
15209
15210        // Discarding the unrelated pre-seed must not discard observed auth.
15211        record_route_credential_failure(
15212            &mut credential,
15213            &with_cloud[0],
15214            &InferenceError::ProviderAccount {
15215                provider: "openai".into(),
15216                status: 401,
15217                message: "invalid API key".into(),
15218            },
15219            false,
15220        );
15221        let terminal = apply_route_failure_context(
15222            InferenceError::InferenceFailed("local model out of memory".into()),
15223            credential.as_ref(),
15224        )
15225        .to_string();
15226        assert!(terminal.contains("openai"), "{terminal}");
15227        assert!(!terminal.contains("Parslee"), "{terminal}");
15228        assert!(is_auth_failure_message(&terminal), "{terminal}");
15229
15230        // An unknown candidate proves nothing and must not keep the pre-seed.
15231        let unknown = vec!["missing/model".to_string()];
15232        assert!(!chain_includes_parslee_route(resolve, &unknown));
15233    }
15234
15235    /// The four terminal failures from the review: with the pre-seed gated out
15236    /// of a local-only chain, none of them may render as a Parslee auth
15237    /// failure or classify as one.
15238    #[test]
15239    fn local_only_terminal_failures_are_not_relabeled_as_auth() {
15240        let cases = [
15241            // Local OOM.
15242            InferenceError::InferenceFailed(
15243                "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
15244                    .into(),
15245            ),
15246            // HTTP 500 from a local server.
15247            InferenceError::Transient {
15248                status: Some(500),
15249                message: "HTTP 500 Internal Server Error".into(),
15250            },
15251            // Crashed llama runner.
15252            InferenceError::InferenceFailed("llama runner process has terminated".into()),
15253        ];
15254        for case in cases {
15255            let error = apply_route_failure_context(case, None).to_string();
15256            assert!(!error.contains("Parslee login is absent"), "{error}");
15257            assert!(!is_auth_failure_message(&error), "{error}");
15258        }
15259
15260        // ModelNotFound keeps the pre-existing setup guidance (which names
15261        // `car auth login` as one of two legitimate remedies) but must not
15262        // open with a fabricated Parslee sign-out.
15263        let error = apply_route_failure_context(
15264            InferenceError::ModelNotFound("qwen/qwen3-4b:q4_k_m".into()),
15265            None,
15266        )
15267        .to_string();
15268        assert!(!error.contains("Parslee login is absent"), "{error}");
15269        assert!(error.contains("car models pull"), "{error}");
15270    }
15271
15272    /// The credential context wraps the terminal error without flattening its
15273    /// type — downstream retry/account logic branches on the variant.
15274    #[test]
15275    fn credential_context_preserves_the_typed_terminal_variant() {
15276        let credential = parslee_signed_out_route_failure();
15277
15278        let transient = apply_route_failure_context(
15279            InferenceError::Transient {
15280                status: Some(500),
15281                message: "HTTP 500 Internal Server Error".into(),
15282            },
15283            Some(&credential),
15284        );
15285        match &transient {
15286            InferenceError::Transient { status, message } => {
15287                assert_eq!(*status, Some(500));
15288                assert!(message.starts_with("Parslee login is absent"), "{message}");
15289                assert!(message.contains("HTTP 500"), "{message}");
15290            }
15291            other => panic!("Transient must stay Transient, got {other:?}"),
15292        }
15293
15294        let account = apply_route_failure_context(
15295            InferenceError::ProviderAccount {
15296                provider: "openai".into(),
15297                status: 402,
15298                message: "insufficient credits".into(),
15299            },
15300            Some(&credential),
15301        );
15302        assert!(
15303            matches!(
15304                &account,
15305                InferenceError::ProviderAccount {
15306                    provider,
15307                    status: 402,
15308                    ..
15309                } if provider == "openai"
15310            ),
15311            "ProviderAccount must stay ProviderAccount, got {account:?}"
15312        );
15313
15314        let unavailable = apply_route_failure_context(
15315            InferenceError::CredentialUnavailable {
15316                provider: "parslee".into(),
15317                model: "parslee/reasoning".into(),
15318                reason: CredentialFailure::Expired { expires_at: 42 },
15319                detail: "access token expired".into(),
15320            },
15321            Some(&credential),
15322        );
15323        match &unavailable {
15324            InferenceError::CredentialUnavailable { reason, detail, .. } => {
15325                assert_eq!(*reason, CredentialFailure::Expired { expires_at: 42 });
15326                assert!(detail.contains("Parslee login is absent"), "{detail}");
15327            }
15328            other => panic!("CredentialUnavailable must keep its reason data, got {other:?}"),
15329        }
15330        assert!(is_auth_failure_message(&unavailable.to_string()));
15331    }
15332
15333    /// The two non-Parslee summaries introduced by the route aggregation must
15334    /// classify through the one shared marker table.
15335    #[test]
15336    fn non_parslee_rejection_summaries_match_the_shared_classifier() {
15337        let rejected = route_credential_failure(
15338            "openai/gpt-5.6",
15339            &InferenceError::ProviderAccount {
15340                provider: "openai".into(),
15341                status: 403,
15342                message: "key revoked".into(),
15343            },
15344            false,
15345        )
15346        .expect("a 403 from a configured provider is actionable");
15347        assert!(rejected.contains("credential was rejected"), "{rejected}");
15348        assert!(is_auth_failure_message(&rejected), "{rejected}");
15349
15350        let generic = route_credential_failure(
15351            "openai/gpt-5.6",
15352            &InferenceError::InferenceFailed("upstream said: token expired".into()),
15353            false,
15354        )
15355        .expect("an auth-rejection message from a non-Parslee route is actionable");
15356        assert!(generic.contains("repair its provider login"), "{generic}");
15357        assert!(is_auth_failure_message(&generic), "{generic}");
15358    }
15359
15360    /// Order is advice. The out-of-the-box agent runs on Parslee inference, so
15361    /// a person who hit an exhausted chain without configuring anything is
15362    /// fixed by signing in — the local pull only helps a model they name. Both
15363    /// remedies stay; which one is read first changes.
15364    #[test]
15365    fn the_recovery_hints_lead_with_parslee_sign_in() {
15366        for hint in [
15367            no_backend_recovery_hint("no credential for proprietary provider 'parslee'")
15368                .expect("a missing credential is a no-backend case"),
15369            auth_expired_recovery_hint(
15370                "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
15371            )
15372            .expect("a rejected session is an auth case"),
15373        ] {
15374            let login = hint
15375                .find("car auth login")
15376                .expect("sign-in must be offered");
15377            let pull = hint
15378                .find("car models pull")
15379                .expect("the local path must still be offered");
15380            assert!(login < pull, "sign-in must be read first:\n{hint}");
15381        }
15382        // The opening marker is a classifier substring for the coder loop and
15383        // the coder-ab harness; reordering the body must not touch it.
15384        assert!(
15385            no_backend_recovery_hint("no credential for proprietary provider 'parslee'")
15386                .expect("hint")
15387                .starts_with(NO_BACKEND_RECOVERY_MARKER),
15388            "the marker opening is load-bearing"
15389        );
15390    }
15391
15392    /// A signed-out fresh install exhausts with a no-backend error; the
15393    /// credential cause must not displace the `car models pull` setup path.
15394    #[test]
15395    fn fresh_install_exhaustion_keeps_the_models_pull_guidance() {
15396        let credential = parslee_signed_out_route_failure();
15397        let error = apply_route_failure_context(
15398            InferenceError::InferenceFailed("no models available for generate".into()),
15399            Some(&credential),
15400        )
15401        .to_string();
15402        assert!(error.contains("Parslee login is absent"), "{error}");
15403        assert!(
15404            error.contains("car models pull qwen/qwen3-4b:q4_k_m"),
15405            "{error}"
15406        );
15407        assert!(error.contains("car auth login"), "{error}");
15408    }
15409
15410    /// The field is absent from the wire on the common path (so no existing
15411    /// client sees a new key) and present when a lane was skipped.
15412    #[test]
15413    fn auth_fallback_from_round_trips_and_defaults_to_none() {
15414        let mut result: InferenceResult = serde_json::from_value(serde_json::json!({
15415            "text": "hi",
15416            "tool_calls": [],
15417            "trace_id": "t",
15418            "model_used": "openai/gpt-5.6",
15419            "latency_ms": 1,
15420        }))
15421        .expect("a payload without the field still deserializes");
15422        assert_eq!(result.auth_fallback_from, None);
15423        let json = serde_json::to_value(&result).unwrap();
15424        assert!(json.get("auth_fallback_from").is_none());
15425
15426        result.auth_fallback_from = Some("parslee/reasoning".to_string());
15427        let json = serde_json::to_value(&result).unwrap();
15428        assert_eq!(json["auth_fallback_from"], "parslee/reasoning");
15429        let back: InferenceResult = serde_json::from_value(json).unwrap();
15430        assert_eq!(
15431            back.auth_fallback_from.as_deref(),
15432            Some("parslee/reasoning")
15433        );
15434    }
15435
15436    /// A successful turn carries both the exact model that served it and an
15437    /// explicit marker when that model was the appended on-device last resort.
15438    /// The marker defaults false for older payloads, so this is additive on the
15439    /// wire rather than making old clients invent attribution.
15440    #[test]
15441    fn local_last_resort_turn_carries_model_id_and_flag() {
15442        let ordinary: InferenceResult = serde_json::from_value(serde_json::json!({
15443            "text": "hi",
15444            "tool_calls": [],
15445            "trace_id": "ordinary",
15446            "model_used": "anthropic/claude-haiku-4-5:latest",
15447            "latency_ms": 1,
15448        }))
15449        .expect("older payloads default the marker");
15450        assert!(!ordinary.local_last_resort);
15451
15452        let fallback: InferenceResult = serde_json::from_value(serde_json::json!({
15453            "text": "offline answer",
15454            "tool_calls": [],
15455            "trace_id": "fallback",
15456            "model_used": "Qwen3 4B MLX",
15457            "resolved_model_id": "mlx/qwen3-4b:4bit",
15458            "latency_ms": 1,
15459            "local_last_resort": true,
15460        }))
15461        .expect("fallback attribution payload");
15462        assert_eq!(fallback.served_model_id(), "mlx/qwen3-4b:4bit");
15463        assert!(fallback.local_last_resort);
15464
15465        assert!(!is_local_last_resort(None, "mlx/qwen3-4b:4bit"));
15466        assert!(report_local_last_resort_served(
15467            Some("mlx/qwen3-4b:4bit"),
15468            "mlx/qwen3-4b:4bit",
15469            "mlx/qwen3-4b:4bit"
15470        ));
15471        assert!(!is_local_last_resort(
15472            Some("mlx/qwen3-4b:4bit"),
15473            "anthropic/claude-haiku-4-5:latest"
15474        ));
15475    }
15476
15477    #[test]
15478    fn no_backend_hint_passes_through_transient_errors() {
15479        // Real failures on otherwise-configured models must NOT be
15480        // relabeled as "no backend / run setup" — they pass through.
15481        for phrase in [
15482            "API returned 401 Unauthorized",
15483            "API returned 429 Too Many Requests",
15484            "API returned 500 Internal Server Error",
15485            "connection refused",
15486            "request timed out",
15487            "parse response: unexpected end of input",
15488        ] {
15489            assert!(
15490                no_backend_recovery_hint(phrase).is_none(),
15491                "transient error wrongly classified as no-backend: {phrase:?}"
15492            );
15493        }
15494    }
15495
15496    /// Tests that mutate process-wide env vars must hold this lock to avoid
15497    /// races with parallel tests (env vars are global mutable state).
15498    static ENV_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
15499
15500    #[cfg(unix)]
15501    #[tokio::test]
15502    async fn exact_codex_subscription_row_dispatches_without_an_openai_key() {
15503        use std::os::unix::fs::PermissionsExt;
15504
15505        let _environment = ENV_MUTEX.lock().await;
15506        let _restore = RestoredEnvironment::capture(&["CAR_CODEX_BIN", "OPENAI_API_KEY"]);
15507        let tmp = TempDir::new().unwrap();
15508        let fixture = tmp.path().join("codex-fixture.sh");
15509        std::fs::write(
15510            &fixture,
15511            r#"#!/bin/sh
15512if [ -n "${OPENAI_API_KEY-}" ]; then
15513  echo 'OPENAI_API_KEY leaked' >&2
15514  exit 91
15515fi
15516cat >/dev/null
15517printf '%s\n' '{"type":"turn.started"}'
15518printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture newsroom answer"}}'
15519printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":17,"output_tokens":5}}'
15520"#,
15521        )
15522        .unwrap();
15523        std::fs::set_permissions(&fixture, std::fs::Permissions::from_mode(0o700)).unwrap();
15524        unsafe {
15525            std::env::set_var("CAR_CODEX_BIN", &fixture);
15526            std::env::set_var("OPENAI_API_KEY", "must-not-reach-codex");
15527        }
15528
15529        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
15530        let mut request = GenerateRequest {
15531            prompt: "write a brief".into(),
15532            params: GenerateParams {
15533                max_tokens: 1056,
15534                strict_model: true,
15535                ..Default::default()
15536            },
15537            ..Default::default()
15538        };
15539        pin_exact_model_id(&mut request, "openai/gpt-5.6-sol:high".into()).unwrap();
15540        let result = engine.generate_tracked(request).await.unwrap();
15541
15542        assert_eq!(result.model_used, "openai/gpt-5.6-sol:high");
15543        assert_eq!(result.text, "fixture newsroom answer");
15544        assert_eq!(result.usage.as_ref().unwrap().total_tokens, 22);
15545        assert_eq!(result.stop_reason, None, "Codex reports no finish reason");
15546        assert!(result.tool_calls.is_empty());
15547    }
15548
15549    #[tokio::test]
15550    async fn isolated_test_child_removes_parent_auth_lock_override() {
15551        const CHILD_SENTINEL: &str = "CAR_AUTH_LOCK_SANITIZER_CHILD";
15552        if std::env::var_os(CHILD_SENTINEL).is_some() {
15553            assert!(
15554                std::env::var_os("CAR_AUTH_LOCK_PATH").is_none(),
15555                "an isolated credential test must not inherit the invoking process's auth lock"
15556            );
15557            return;
15558        }
15559
15560        let _environment = ENV_MUTEX.lock().await;
15561        let _restore = RestoredEnvironment::capture(&["CAR_AUTH_LOCK_PATH"]);
15562        unsafe {
15563            std::env::set_var(
15564                "CAR_AUTH_LOCK_PATH",
15565                "/sentinel/must-not-reach-isolated-child.lock",
15566            );
15567        }
15568        assert!(!crate::run_in_isolated_test_process(
15569            "tests::isolated_test_child_removes_parent_auth_lock_override",
15570            CHILD_SENTINEL,
15571        ));
15572    }
15573
15574    fn test_config(models_dir: PathBuf) -> InferenceConfig {
15575        // Keep every state file inside the test's own tree: the state root is
15576        // the models dir's parent, so `state_models_dir()` lands back on
15577        // `models_dir` exactly as it did before the root was split out.
15578        let state_root = models_dir
15579            .parent()
15580            .map(Path::to_path_buf)
15581            .unwrap_or_else(|| models_dir.clone());
15582        InferenceConfig {
15583            models_dir,
15584            state_root,
15585            device: None,
15586            generation_model: "Qwen3-0.6B".into(),
15587            preferred_generation_model: None,
15588            embedding_model: "Qwen3-Embedding-0.6B".into(),
15589            preferred_embedding_model: None,
15590            classification_model: "Qwen3-0.6B".into(),
15591            preferred_classification_model: None,
15592        }
15593    }
15594
15595    fn metal_mac_for_fit(ram_gb: u64) -> HardwareInfo {
15596        HardwareInfo {
15597            os: "macos".into(),
15598            arch: "aarch64".into(),
15599            cpu_cores: 8,
15600            total_ram_mb: ram_gb * 1024,
15601            gpu_backend: crate::hardware::GpuBackend::Metal,
15602            gpu_memory_mb: None,
15603            gpu_devices: vec![],
15604            recommended_model: String::new(),
15605            recommended_context: 8_192,
15606            max_model_mb: 0,
15607        }
15608    }
15609
15610    /// car#1399: the unified catalog is annotated for the machine it is
15611    /// asked about, without any row being dropped, reordered, or otherwise
15612    /// changed. Under Everyday (40% of unified memory) the builtin
15613    /// `mlx/qwen3-8b:4bit` (~6.3 GB at the recommendation context) is too
15614    /// big for an 8 GB Mac and fits a 32 GB one; a deprecated row stays
15615    /// listed and says so.
15616    #[test]
15617    fn unified_rows_carry_fit_per_machine_and_keep_deprecated_rows() {
15618        if !crate::run_in_isolated_test_process(
15619            "tests::unified_rows_carry_fit_per_machine_and_keep_deprecated_rows",
15620            "CAR_UNIFIED_ROWS_FIT_TEST_CHILD",
15621        ) {
15622            return;
15623        }
15624
15625        // Pin OpenRouter credential availability for BOTH snapshots below.
15626        // The remote-row set depends on whether a credential resolves, and
15627        // that resolution is process-global — a parallel test flipping the
15628        // override between the at_8 and at_32 listings made the two id sets
15629        // differ with nothing wrong in the catalog (car-sap7, and the trace
15630        // on the bead: availability differed between two snapshots).
15631        let _credential_scope = crate::openrouter::test_credential_scope();
15632        crate::openrouter::set_test_credential(Some("unified-rows-fit-test-key"));
15633        let secret_activity_before = car_secrets::secret_store_activity();
15634        let root = tempfile::tempdir().unwrap();
15635        let config = test_config(root.path().join("weights"));
15636        let mut engine = InferenceEngine::new(config);
15637        let mut retired = engine
15638            .list_schemas()
15639            .into_iter()
15640            .find(|schema| schema.id == "mlx/qwen3-4b:4bit")
15641            .expect("builtin 4B MLX row");
15642        retired.id = "test/retired-4b:4bit".into();
15643        retired.name = "Retired 4B".into();
15644        retired.deprecated = true;
15645        engine.register_model(retired);
15646        let policy = resource_policy::ResourcePolicy::everyday();
15647
15648        let at_8 = engine.list_models_unified_for(&metal_mac_for_fit(8), &policy);
15649        let at_32 = engine.list_models_unified_for(&metal_mac_for_fit(32), &policy);
15650        let ids = |rows: &[ModelInfo]| rows.iter().map(|row| row.id.clone()).collect::<Vec<_>>();
15651        assert_eq!(
15652            ids(&at_8),
15653            ids(&at_32),
15654            "the machine never removes or reorders a row"
15655        );
15656        let row = |rows: &[ModelInfo], id: &str| {
15657            rows.iter()
15658                .find(|row| row.id == id)
15659                .unwrap_or_else(|| panic!("{id} missing"))
15660                .clone()
15661        };
15662
15663        let eight_b_small = row(&at_8, "mlx/qwen3-8b:4bit");
15664        let eight_b_large = row(&at_32, "mlx/qwen3-8b:4bit");
15665        assert_eq!(eight_b_small.fit, ModelFitStatus::TooBig);
15666        assert_eq!(eight_b_large.fit, ModelFitStatus::Fits);
15667        assert!(eight_b_small.platform_compatible && eight_b_large.platform_compatible);
15668        assert_eq!(
15669            eight_b_small.estimated_peak_mb, eight_b_large.estimated_peak_mb,
15670            "the estimate is the model's; only the budget differs"
15671        );
15672        assert!(eight_b_small.estimated_peak_mb.is_some_and(|mb| mb > 4_800));
15673        assert_eq!(eight_b_small.family.as_deref(), Some("qwen3"));
15674        assert!(eight_b_small.version.is_some());
15675
15676        let retired = row(&at_8, "test/retired-4b:4bit");
15677        assert!(retired.deprecated, "deprecated rows stay listed, flagged");
15678        assert!(!eight_b_small.deprecated);
15679
15680        let mut saw_remote = false;
15681        for remote in at_8.iter().filter(|row| !row.is_local) {
15682            saw_remote = true;
15683            assert_eq!(remote.fit, ModelFitStatus::Fits, "{}", remote.id);
15684            assert!(remote.platform_compatible, "{}", remote.id);
15685            assert_eq!(remote.estimated_peak_mb, None, "{}", remote.id);
15686            assert_eq!(
15687                remote.family, None,
15688                "{}: no upstream identifier here",
15689                remote.id
15690            );
15691            assert_eq!(remote.version, None, "{}", remote.id);
15692        }
15693        assert!(saw_remote);
15694        assert_eq!(
15695            car_secrets::secret_store_activity(),
15696            secret_activity_before,
15697            "catalog snapshots must use the injected credential without consulting host secrets"
15698        );
15699
15700        // Every existing field is byte-identical across machines: strip the
15701        // three fit keys and the rows must serialize the same.
15702        for (small, large) in at_8.iter().zip(&at_32) {
15703            let strip = |row: &ModelInfo| {
15704                let mut value = serde_json::to_value(row).unwrap();
15705                let object = value.as_object_mut().unwrap();
15706                for key in ["fit", "estimated_peak_mb", "platform_compatible"] {
15707                    object.remove(key);
15708                }
15709                value
15710            };
15711            assert_eq!(strip(small), strip(large), "{}", small.id);
15712        }
15713    }
15714
15715    #[tokio::test]
15716    async fn explicit_model_blocked_by_zero_budget_does_not_substitute_or_download() {
15717        let root = tempfile::tempdir().unwrap();
15718        let models_dir = root.path().join("weights");
15719        let config = test_config(models_dir.clone());
15720        let repository =
15721            crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
15722        crate::resource_policy::ResourcePolicyRepository::save(
15723            &repository,
15724            &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
15725        )
15726        .unwrap();
15727        let engine = InferenceEngine::new(config);
15728
15729        let error = engine
15730            .generate_tracked(GenerateRequest {
15731                prompt: "hello".into(),
15732                model: Some("mlx/qwen3-4b:4bit".into()),
15733                ..Default::default()
15734            })
15735            .await
15736            .unwrap_err();
15737
15738        assert!(matches!(
15739            error,
15740            InferenceError::LocalResourceBlocked {
15741                preflight: crate::resource_policy::LocalLoadPreflight {
15742                    verdict: crate::resource_policy::LocalLoadVerdict::DisabledByPolicy,
15743                    ..
15744                },
15745                ..
15746            }
15747        ));
15748        assert!(
15749            !models_dir.exists() || std::fs::read_dir(models_dir).unwrap().next().is_none(),
15750            "admission must happen before download/load"
15751        );
15752    }
15753
15754    #[tokio::test]
15755    async fn external_vllm_mlx_bypasses_local_admission_while_managed_artifact_does_not() {
15756        let schema = crate::vllm_mlx::to_model_schema(
15757            &crate::vllm_mlx::DiscoveredModel {
15758                id: "mlx-community/Qwen3-4B-4bit".into(),
15759                owned_by: None,
15760            },
15761            "http://localhost:8000",
15762        );
15763        assert!(!InferenceEngine::requires_local_admission(&schema));
15764
15765        let root = tempfile::tempdir().unwrap();
15766        let config = test_config(root.path().join("weights"));
15767        let repository =
15768            crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
15769        crate::resource_policy::ResourcePolicyRepository::save(
15770            &repository,
15771            &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
15772        )
15773        .unwrap();
15774        let engine = InferenceEngine::new(config);
15775        let (external, reservation) = engine
15776            .vllm_live_schema(schema.clone(), None, 0)
15777            .await
15778            .unwrap();
15779        assert!(matches!(
15780            external.source,
15781            ModelSource::VllmMlx { ref endpoint, .. } if endpoint == "http://localhost:8000"
15782        ));
15783        assert!(reservation.is_none());
15784
15785        let mut managed = schema;
15786        managed.source = ModelSource::ManagedVllmMlx {
15787            hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
15788            hf_weight_file: None,
15789        };
15790        assert!(InferenceEngine::requires_local_admission(&managed));
15791        let error = engine
15792            .reserve_local_request(&managed, 0)
15793            .expect_err("zero-GB policy must reject before vllm runtime/download/spawn");
15794        assert!(matches!(
15795            error,
15796            InferenceError::LocalResourceBlocked {
15797                preflight: crate::resource_policy::LocalLoadPreflight {
15798                    verdict: crate::resource_policy::LocalLoadVerdict::DisabledByPolicy,
15799                    ..
15800                },
15801                ..
15802            }
15803        ));
15804    }
15805
15806    #[cfg(unix)]
15807    async fn assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(
15808        streaming: bool,
15809        cancel_first: bool,
15810    ) {
15811        struct FixedProbe;
15812        impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
15813            fn available_memory_mb(
15814                &self,
15815            ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
15816                Ok(Some(24_000))
15817            }
15818        }
15819
15820        let Some(python) = crate::vllm_runtime::test_python_interpreter() else {
15821            panic!("a real Python interpreter is required for the managed-vllm dispatch fixture");
15822        };
15823        let root = tempfile::tempdir().unwrap();
15824        let script = root.path().join("fake-vllm-mlx");
15825        let spawned = root.path().join("spawned");
15826        let release = root.path().join("release");
15827        std::fs::write(
15828            &script,
15829            format!(
15830                "#!{}\n\
15831                 import http.server, os, sys, time\n\
15832                 marker = {:?}\n\
15833                 release = {:?}\n\
15834                 open(marker, 'w').close()\n\
15835                 while not os.path.exists(release): time.sleep(0.01)\n\
15836                 port = int(sys.argv[sys.argv.index('--port') + 1])\n\
15837                 class H(http.server.BaseHTTPRequestHandler):\n\
15838                 \x20   def do_GET(self):\n\
15839                 \x20       self.send_response(200); self.end_headers(); self.wfile.write(b'ok')\n\
15840                 \x20   def do_POST(self):\n\
15841                 \x20       length = int(self.headers.get('content-length', '0'))\n\
15842                 \x20       request = self.rfile.read(length).replace(b' ', b'')\n\
15843                 \x20       if b'\"stream\":true' in request:\n\
15844                 \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\
15845                 \x20           content_type = 'text/event-stream'\n\
15846                 \x20       else:\n\
15847                 \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\
15848                 \x20           content_type = 'application/json'\n\
15849                 \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\
15850                 \x20   def log_message(self, *args): pass\n\
15851                 http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()\n",
15852                python.display(),
15853                spawned.display().to_string(),
15854                release.display().to_string(),
15855            ),
15856        )
15857        .unwrap();
15858        use std::os::unix::fs::PermissionsExt;
15859        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
15860
15861        let models_dir = root.path().join("models");
15862        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
15863        let mut schema = crate::registry::builtin_catalog()
15864            .into_iter()
15865            .find(ModelSchema::is_car_managed_vllm_mlx)
15866            .expect("managed vllm fixture schema");
15867        schema.id = "vllm-mlx/round12-singleflight".into();
15868        schema.name = "round12-singleflight".into();
15869        schema.cost.size_mb = Some(1);
15870        schema.cost.ram_mb = Some(1);
15871        schema.source = ModelSource::ManagedVllmMlx {
15872            hf_repo: "fixture/round12-singleflight".into(),
15873            hf_weight_file: None,
15874        };
15875        let model_dir = models_dir.join(&schema.name);
15876        std::fs::create_dir_all(&model_dir).unwrap();
15877        std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
15878        std::fs::write(model_dir.join("model.safetensors"), b"fixture").unwrap();
15879        engine
15880            .unified_registry
15881            .register_project_model(schema.clone());
15882
15883        let coordinator = Arc::new(
15884            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
15885                // Two concurrent Linux requests each carry 1 GiB of runtime
15886                // overhead plus context/transient headroom. Keep the fixture
15887                // above that platform-specific total so this test exercises
15888                // dispatch singleflight rather than an unrelated ceiling.
15889                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
15890                crate::hardware::HardwareInfo::detect(),
15891                Arc::new(FixedProbe),
15892            ),
15893        );
15894        engine.local_admission = coordinator.clone();
15895        engine.vllm_pool = Arc::new(crate::vllm_pool::VllmServerPool::with_test_runtime(
15896            std::time::Duration::from_secs(300),
15897            coordinator,
15898            script,
15899        ));
15900        let engine = Arc::new(engine);
15901        let model_id = schema.id.clone();
15902        let mut request = GenerateRequest {
15903            prompt: "hello from round 13".into(),
15904            model: Some(model_id.clone()),
15905            ..Default::default()
15906        };
15907        // A fixture failure must stay a fixture failure. Without strict model
15908        // routing, the streaming case escaped to a configured provider and
15909        // returned real, nondeterministic prose instead of exercising this
15910        // managed-vLLM singleflight path.
15911        request.params.strict_model = true;
15912        let (estimated_input, _, _) = engine.estimated_tokens(&request, Some(&model_id));
15913        let expected_footprint = estimated_input.saturating_add(request.params.max_tokens);
15914        let expected_estimate = engine
15915            .local_model_preflight(&model_id, expected_footprint)
15916            .unwrap()
15917            .estimate;
15918        let expected_request_overhead_mb = expected_estimate
15919            .runtime_overhead_mb
15920            .saturating_add(expected_estimate.context_overhead_mb)
15921            .saturating_add(expected_estimate.transient_margin_mb);
15922
15923        async fn execute(
15924            engine: Arc<InferenceEngine>,
15925            request: GenerateRequest,
15926            streaming: bool,
15927            direct_vllm: bool,
15928        ) -> Result<String, InferenceError> {
15929            if direct_vllm {
15930                let model_id = request.model.as_deref().expect("explicit fixture model");
15931                let schema = engine
15932                    .unified_registry
15933                    .get(model_id)
15934                    .cloned()
15935                    .expect("fixture schema");
15936                let (schema, _) = engine.vllm_live_schema(schema, None, 0).await?;
15937                return match schema.source {
15938                    ModelSource::VllmMlx { endpoint, .. } => Ok(endpoint),
15939                    source => Err(InferenceError::InferenceFailed(format!(
15940                        "fixture did not resolve to a vllm endpoint: {source:?}"
15941                    ))),
15942                };
15943            }
15944            if !streaming {
15945                return engine
15946                    .generate_tracked(request)
15947                    .await
15948                    .map(|result| result.text);
15949            }
15950            let mut tracked = engine.generate_tracked_stream(request).await?;
15951            let mut accumulator = crate::stream::StreamAccumulator::default();
15952            while let Some(event) = tracked.events.recv().await {
15953                let done = matches!(event, crate::stream::StreamEvent::Done { .. });
15954                accumulator.push(&event);
15955                if done {
15956                    break;
15957                }
15958            }
15959            Ok(accumulator.finish().0)
15960        }
15961
15962        let first_engine = engine.clone();
15963        let first_request = request.clone();
15964        let mut first = tokio::spawn(async move {
15965            execute(first_engine, first_request, streaming, cancel_first).await
15966        });
15967        tokio::time::timeout(std::time::Duration::from_secs(5), async {
15968            while !spawned.exists() {
15969                if first.is_finished() {
15970                    let result = (&mut first).await;
15971                    panic!("first dispatch ended before spawn: {result:?}");
15972                }
15973                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
15974            }
15975        })
15976        .await
15977        .expect("first request must spawn before publication");
15978        if !cancel_first {
15979            let during_startup = engine.local_model_preflight(&model_id, 0).unwrap();
15980            assert_eq!(
15981                during_startup.active_reservations_mb, expected_request_overhead_mb,
15982                "the cold-to-pending transfer must retain all request context/KV overhead"
15983            );
15984            assert_eq!(
15985                during_startup.resident_model_mb, expected_estimate.weights_mb,
15986                "the pending allocation must replace the exact cold-weight charge"
15987            );
15988            assert_eq!(
15989                during_startup
15990                    .active_reservations_mb
15991                    .saturating_add(during_startup.resident_model_mb),
15992                expected_estimate.estimated_peak_mb,
15993                "active request overhead plus pending weights must preserve the full admitted footprint"
15994            );
15995        }
15996
15997        let second_engine = engine.clone();
15998        let mut second =
15999            tokio::spawn(
16000                async move { execute(second_engine, request, streaming, cancel_first).await },
16001            );
16002        assert!(
16003            tokio::time::timeout(std::time::Duration::from_millis(100), &mut second)
16004                .await
16005                .is_err(),
16006            "request 2 must wait for request 1 to publish instead of failing on startup state"
16007        );
16008
16009        let first_result = if cancel_first {
16010            std::fs::remove_file(&spawned).unwrap();
16011            first.abort();
16012            let _ = first.await;
16013            tokio::time::timeout(std::time::Duration::from_secs(5), async {
16014                while !spawned.exists() {
16015                    if second.is_finished() {
16016                        let result = (&mut second).await;
16017                        panic!("waiter ended before replacement spawn: {result:?}");
16018                    }
16019                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
16020                }
16021            })
16022            .await
16023            .expect("waiter must continue after cancelled owner teardown");
16024            None
16025        } else {
16026            std::fs::write(&release, b"release").unwrap();
16027            Some(
16028                tokio::time::timeout(std::time::Duration::from_secs(5), first)
16029                    .await
16030                    .expect("first dispatch completes")
16031                    .unwrap()
16032                    .unwrap(),
16033            )
16034        };
16035        std::fs::write(&release, b"release").unwrap();
16036        let second_result = tokio::time::timeout(std::time::Duration::from_secs(5), second)
16037            .await
16038            .expect("second dispatch completes")
16039            .unwrap()
16040            .unwrap();
16041        let expected = if cancel_first {
16042            None
16043        } else if streaming {
16044            Some("round13-stream")
16045        } else {
16046            Some("round13-ok")
16047        };
16048        if let (Some(first_result), Some(expected)) = (first_result, expected) {
16049            assert_eq!(first_result, expected);
16050        }
16051        if let Some(expected) = expected {
16052            assert_eq!(second_result, expected);
16053        } else {
16054            assert!(second_result.starts_with("http://127.0.0.1:"));
16055        }
16056        assert_eq!(engine.vllm_pool.len().await, 1);
16057        assert!(engine
16058            .vllm_pool
16059            .release_model_if_present(&model_id)
16060            .await
16061            .unwrap());
16062    }
16063
16064    #[cfg(unix)]
16065    #[tokio::test]
16066    async fn concurrent_managed_vllm_generate_waits_before_outer_reservation() {
16067        assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(false, false).await;
16068    }
16069
16070    #[cfg(unix)]
16071    #[tokio::test]
16072    async fn concurrent_managed_vllm_stream_waits_before_outer_reservation() {
16073        assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(true, false).await;
16074    }
16075
16076    #[cfg(unix)]
16077    #[tokio::test]
16078    async fn managed_vllm_waiter_continues_after_startup_owner_cancellation() {
16079        assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(false, true).await;
16080    }
16081
16082    #[tokio::test]
16083    async fn same_state_root_engines_share_runtime_components() {
16084        const CHILD_ENV: &str = "CAR_TWO_ENGINE_RUNTIME_TEST_CHILD";
16085        if std::env::var_os(CHILD_ENV).is_some() {
16086            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
16087            return;
16088        }
16089        let root = tempfile::tempdir().unwrap();
16090        let first = InferenceEngine::new(test_config(root.path().join("weights")));
16091        let second = InferenceEngine::new(test_config(root.path().join("weights")));
16092
16093        assert!(Arc::ptr_eq(&first.model_budget, &second.model_budget));
16094        assert!(Arc::ptr_eq(&first.vllm_pool, &second.vllm_pool));
16095        assert!(Arc::ptr_eq(
16096            &first.resource_policy_generation,
16097            &second.resource_policy_generation
16098        ));
16099        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
16100        {
16101            assert!(Arc::ptr_eq(&first.mlx_backends, &second.mlx_backends));
16102            assert!(Arc::ptr_eq(&first.local_backends, &second.local_backends));
16103            assert!(Arc::ptr_eq(&first.flux_cache, &second.flux_cache));
16104            assert!(Arc::ptr_eq(&first.ltx_cache, &second.ltx_cache));
16105            assert!(Arc::ptr_eq(&first.kokoro_cache, &second.kokoro_cache));
16106        }
16107        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
16108        {
16109            assert!(Arc::ptr_eq(&first.backend, &second.backend));
16110            assert!(Arc::ptr_eq(
16111                &first.embedding_backend,
16112                &second.embedding_backend
16113            ));
16114        }
16115
16116        let loads = Arc::new(std::sync::atomic::AtomicU64::new(0));
16117        let first_cache = first._runtime_scope.load_probe.clone();
16118        let second_cache = second._runtime_scope.load_probe.clone();
16119        let mut threads = Vec::new();
16120        for cache in [first_cache, second_cache] {
16121            let loads = loads.clone();
16122            threads.push(std::thread::spawn(move || {
16123                cache
16124                    .get_or_load::<()>("same/model", 1, || {
16125                        loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16126                        std::thread::sleep(std::time::Duration::from_millis(20));
16127                        Ok(())
16128                    })
16129                    .unwrap()
16130            }));
16131        }
16132        let handles = threads
16133            .into_iter()
16134            .map(|thread| thread.join().unwrap())
16135            .collect::<Vec<_>>();
16136        assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
16137        assert!(Arc::ptr_eq(&handles[0], &handles[1]));
16138
16139        let child = tokio::process::Command::new(std::env::current_exe().unwrap())
16140            .arg("--exact")
16141            .arg("tests::same_state_root_engines_share_runtime_components")
16142            .env(CHILD_ENV, "1")
16143            .stdin(std::process::Stdio::null())
16144            .stdout(std::process::Stdio::null())
16145            .stderr(std::process::Stdio::null())
16146            .kill_on_drop(true)
16147            .spawn()
16148            .unwrap();
16149        first
16150            .vllm_pool
16151            .insert_test_process("vllm-mlx/two-engine", child)
16152            .await;
16153        first.local_admission.mark_resident_allocation(
16154            "vllm-mlx/two-engine",
16155            &resource_policy::vllm_process_allocation_id("vllm-mlx/two-engine"),
16156            1,
16157        );
16158        assert!(second.vllm_pool.contains("vllm-mlx/two-engine").await);
16159        drop(first);
16160        assert!(second.vllm_pool.contains("vllm-mlx/two-engine").await);
16161        assert!(second.local_admission.is_resident("vllm-mlx/two-engine"));
16162        assert!(second.vllm_pool.evict_model("vllm-mlx/two-engine").await);
16163        assert!(!second.local_admission.is_resident("vllm-mlx/two-engine"));
16164    }
16165
16166    #[tokio::test]
16167    async fn last_engine_drop_reaps_vllm_before_new_runtime_admission() {
16168        const CHILD_ENV: &str = "CAR_LAST_ENGINE_VLLM_DROP_TEST_CHILD";
16169        if std::env::var_os(CHILD_ENV).is_some() {
16170            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
16171            return;
16172        }
16173        let root = tempfile::tempdir().unwrap();
16174        let config = test_config(root.path().join("weights"));
16175        let engine = InferenceEngine::new(config.clone());
16176        let coordinator = engine.local_admission.clone();
16177        let schema = engine
16178            .unified_registry
16179            .all()
16180            .find(|schema| schema.is_vllm_mlx())
16181            .cloned()
16182            .expect("supervised vllm schema");
16183        let child = tokio::process::Command::new(std::env::current_exe().unwrap())
16184            .arg("--exact")
16185            .arg("tests::last_engine_drop_reaps_vllm_before_new_runtime_admission")
16186            .env(CHILD_ENV, "1")
16187            .stdin(std::process::Stdio::null())
16188            .stdout(std::process::Stdio::null())
16189            .stderr(std::process::Stdio::null())
16190            .kill_on_drop(true)
16191            .spawn()
16192            .unwrap();
16193        engine
16194            .vllm_pool
16195            .insert_test_process(&schema.id, child)
16196            .await;
16197        coordinator.mark_resident_allocation(
16198            &schema.id,
16199            &resource_policy::vllm_process_allocation_id(&schema.id),
16200            1,
16201        );
16202
16203        drop(engine);
16204
16205        tokio::time::timeout(std::time::Duration::from_secs(2), async {
16206            while coordinator.teardown_pending(&schema.id) {
16207                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
16208            }
16209        })
16210        .await
16211        .expect("last runtime drop must eventually confirm supervised child exit");
16212        assert!(!coordinator.is_resident(&schema.id));
16213        let next = InferenceEngine::new(config);
16214        assert!(Arc::ptr_eq(&coordinator, &next.local_admission));
16215        let preflight = next.local_model_preflight(&schema.id, 0).unwrap();
16216        assert_eq!(preflight.resident_model_mb, 0);
16217        assert!(preflight.estimated_incremental_mb >= preflight.estimate.weights_mb);
16218    }
16219
16220    #[cfg(unix)]
16221    #[test]
16222    fn symlinked_state_roots_share_exact_coordinator_and_runtime() {
16223        use std::os::unix::fs::symlink;
16224
16225        let fixture = tempfile::tempdir().unwrap();
16226        let real = fixture.path().join("real-state");
16227        std::fs::create_dir(&real).unwrap();
16228        let alias = fixture.path().join("state-alias");
16229        symlink(&real, &alias).unwrap();
16230
16231        let first = InferenceEngine::new(test_config(real.join("weights")));
16232        let second = InferenceEngine::new(test_config(alias.join("weights")));
16233        assert!(Arc::ptr_eq(&first.local_admission, &second.local_admission));
16234        assert!(Arc::ptr_eq(&first._runtime_scope, &second._runtime_scope));
16235        assert!(Arc::ptr_eq(&first.model_budget, &second.model_budget));
16236    }
16237
16238    #[tokio::test]
16239    async fn stream_reservation_lives_until_returned_receiver_is_released() {
16240        let root = tempfile::tempdir().unwrap();
16241        let engine = InferenceEngine::new(test_config(root.path().join("weights")));
16242        let schema = crate::registry::builtin_catalog()
16243            .into_iter()
16244            .find(|schema| schema.is_local() && !schema.is_vllm_mlx())
16245            .expect("local model schema");
16246        engine.local_admission.mark_resident(&schema.id, 1);
16247        let reservation = engine.reserve_local_request(&schema, 64).unwrap();
16248        assert_eq!(engine.local_admission.active_request_count(&schema.id), 1);
16249        let (source_tx, source_rx) = tokio::sync::mpsc::channel(1);
16250        let returned =
16251            InferenceEngine::hold_optional_reservation_for_stream(source_rx, Some(reservation));
16252
16253        drop(returned);
16254        source_tx
16255            .send(stream::StreamEvent::TextDelta("release".into()))
16256            .await
16257            .unwrap();
16258        for _ in 0..20 {
16259            if engine.local_admission.active_request_count(&schema.id) == 0 {
16260                break;
16261            }
16262            tokio::task::yield_now().await;
16263        }
16264        assert_eq!(engine.local_admission.active_request_count(&schema.id), 0);
16265
16266        let compact_source = include_str!("lib.rs")
16267            .split_whitespace()
16268            .collect::<String>();
16269        let remote_handoff = [
16270            "Self::hold_optional_reservation_for_stream",
16271            "(receiver,",
16272            "candidate_reservation.take(),",
16273            ");",
16274        ]
16275        .concat();
16276        assert!(compact_source.contains(&remote_handoff));
16277    }
16278
16279    #[tokio::test]
16280    async fn residual_voice_allocation_blocks_removal_with_typed_error() {
16281        let root = tempfile::tempdir().unwrap();
16282        let engine = InferenceEngine::new(test_config(root.path().join("weights")));
16283        engine
16284            .local_admission
16285            .mark_resident("voice/removal-fixture", 1);
16286
16287        let error = match engine
16288            .prepare_local_model_removal("voice/removal-fixture")
16289            .await
16290        {
16291            Err(error) => error,
16292            Ok(_) => panic!("voice allocation must be released by its owner first"),
16293        };
16294        assert!(matches!(
16295            error,
16296            crate::resource_policy::ModelMaintenanceError::ResidualResidency {
16297                model_id,
16298                allocation_ids,
16299            } if model_id == "voice/removal-fixture"
16300                && allocation_ids == vec!["voice/removal-fixture"]
16301        ));
16302    }
16303
16304    #[tokio::test]
16305    async fn catalog_voice_id_blocks_removal_of_live_provider_alias() {
16306        let root = tempfile::tempdir().unwrap();
16307        let engine = InferenceEngine::new(test_config(root.path().join("weights")));
16308        engine.local_admission.register_model_aliases(
16309            "voice/parakeet-tdt-0.6b",
16310            ["mlx/parakeet-tdt-0.6b-v3:default"],
16311        );
16312        let mut reservation = engine
16313            .local_admission
16314            .reserve_measured_host_allocation(
16315                "voice/parakeet-tdt-0.6b",
16316                "voice/parakeet-tdt-0.6b#voice-allocation-0",
16317                1024 * 1024,
16318                0,
16319            )
16320            .unwrap();
16321        reservation.publish_resident_weights(1024 * 1024);
16322        drop(reservation);
16323
16324        let result = engine
16325            .prepare_local_model_removal("mlx/parakeet-tdt-0.6b-v3:default")
16326            .await;
16327        assert!(matches!(
16328            result,
16329            Err(crate::resource_policy::ModelMaintenanceError::ResidualResidency { .. })
16330        ));
16331    }
16332
16333    struct RefusingRemovalOffload;
16334
16335    #[async_trait::async_trait]
16336    impl crate::offload::LocalGenerationOffload for RefusingRemovalOffload {
16337        async fn generate(
16338            &self,
16339            _request: GenerateRequest,
16340        ) -> Result<InferenceResult, InferenceError> {
16341            unreachable!("removal test does not generate")
16342        }
16343
16344        async fn stream(
16345            &self,
16346            _request: GenerateRequest,
16347        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
16348            unreachable!("removal test does not stream")
16349        }
16350
16351        async fn resident_models(&self) -> Vec<String> {
16352            vec!["worker/refuses-release".into()]
16353        }
16354
16355        async fn release_model(&self, _model_id: &str) -> Result<bool, InferenceError> {
16356            Ok(false)
16357        }
16358    }
16359
16360    #[tokio::test]
16361    async fn worker_release_boolean_is_required_for_model_removal() {
16362        let _offload_guard = crate::offload::test_offload_lock().lock().await;
16363        crate::offload::set_local_offload(Some(Arc::new(RefusingRemovalOffload)));
16364        let root = tempfile::tempdir().unwrap();
16365        let engine = InferenceEngine::new(test_config(root.path().join("weights")));
16366        let result = engine
16367            .prepare_local_model_removal("worker/refuses-release")
16368            .await;
16369        crate::offload::set_local_offload(None);
16370
16371        assert!(matches!(
16372            result,
16373            Err(crate::resource_policy::ModelMaintenanceError::WorkerReleaseUnacknowledged(
16374                model_id
16375            )) if model_id == "worker/refuses-release"
16376        ));
16377    }
16378
16379    #[test]
16380    fn transient_mlx_vlm_allocation_never_becomes_resident() {
16381        struct FixedProbe;
16382        impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
16383            fn available_memory_mb(
16384                &self,
16385            ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
16386                Ok(Some(24_000))
16387            }
16388        }
16389
16390        let root = tempfile::tempdir().unwrap();
16391        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
16392        engine.local_admission = Arc::new(
16393            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
16394                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
16395                crate::hardware::HardwareInfo {
16396                    total_ram_mb: 32 * 1024,
16397                    ..crate::hardware::HardwareInfo::detect()
16398                },
16399                Arc::new(FixedProbe),
16400            ),
16401        );
16402        let mut schema = crate::registry::builtin_catalog()
16403            .into_iter()
16404            .find(|schema| schema.tags.iter().any(|tag| tag == "mlx-vlm-cli"))
16405            .expect("one-shot mlx-vlm schema");
16406        schema.param_count = "1M".into();
16407        schema.quantization = Some(Quantization::parse("Q4"));
16408        let mut reservation = engine.reserve_local_request(&schema, 64).unwrap();
16409        InferenceEngine::reconcile_transient_local_allocation(&mut reservation, 1024 * 1024)
16410            .unwrap();
16411        drop(reservation);
16412        assert!(!engine.local_admission.is_resident(&schema.id));
16413    }
16414
16415    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
16416    #[test]
16417    fn non_apple_backend_map_keeps_two_local_model_identities_exact() {
16418        let mut backends = std::collections::HashMap::<String, u32>::new();
16419        backends.insert("local/model-a".into(), 1);
16420        backends.insert("local/model-b".into(), 2);
16421        assert_eq!(backends.get("local/model-a"), Some(&1));
16422        assert_eq!(backends.get("local/model-b"), Some(&2));
16423        assert_eq!(backends.remove("local/model-a"), Some(1));
16424        assert!(!backends.contains_key("local/model-a"));
16425        assert_eq!(backends.get("local/model-b"), Some(&2));
16426    }
16427
16428    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
16429    #[tokio::test]
16430    async fn non_apple_remote_classification_at_zero_gb_routes_without_local_allocation() {
16431        use wiremock::matchers::{method, path};
16432        use wiremock::{Mock, MockServer, ResponseTemplate};
16433
16434        let _credential_scope = crate::openrouter::test_credential_scope();
16435        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
16436        let server = MockServer::start().await;
16437        Mock::given(method("POST"))
16438            .and(path("/v1/chat/completions"))
16439            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
16440                "choices": [{
16441                    "message": {"role": "assistant", "content": "positive"},
16442                    "finish_reason": "stop"
16443                }],
16444                "usage": {"prompt_tokens": 16, "completion_tokens": 1}
16445            })))
16446            .mount(&server)
16447            .await;
16448
16449        let fixture = tempfile::tempdir().unwrap();
16450        let config = test_config(fixture.path().join("weights"));
16451        let repository =
16452            crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
16453        crate::resource_policy::ResourcePolicyRepository::save(
16454            &repository,
16455            &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
16456        )
16457        .unwrap();
16458        let mut engine = InferenceEngine::new(config);
16459        let mut remote = crate::openrouter::curated_schemas()
16460            .into_iter()
16461            .next()
16462            .expect("OpenRouter schema");
16463        remote.id = "openrouter/test/classifier".into();
16464        remote.name = remote.id.clone();
16465        if let ModelSource::RemoteApi { endpoint, .. } = &mut remote.source {
16466            *endpoint = server.uri();
16467        }
16468        let remote_id = remote.id.clone();
16469        engine.unified_registry.register_project_model(remote);
16470
16471        let result = engine
16472            .classify(ClassifyRequest {
16473                text: "a good outcome".into(),
16474                labels: vec!["positive".into(), "negative".into()],
16475                model: Some(remote_id),
16476            })
16477            .await
16478            .unwrap();
16479        assert_eq!(
16480            result.first().map(|item| item.label.as_str()),
16481            Some("positive")
16482        );
16483        assert_eq!(engine.local_admission.resident_model_mb(), 0);
16484        assert!(
16485            !engine.config.models_dir.exists()
16486                || std::fs::read_dir(&engine.config.models_dir)
16487                    .unwrap()
16488                    .next()
16489                    .is_none(),
16490            "remote classification must not download local weights"
16491        );
16492    }
16493
16494    #[test]
16495    fn adaptive_speech_skips_blocked_local_but_explicit_speech_fails() {
16496        let fixture = TempDir::new().unwrap();
16497        let config = InferenceConfig {
16498            models_dir: fixture.path().join("weights"),
16499            state_root: fixture.path().join("state"),
16500            ..Default::default()
16501        };
16502        let repository =
16503            crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
16504        crate::resource_policy::ResourcePolicyRepository::save(
16505            &repository,
16506            &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
16507        )
16508        .unwrap();
16509        let engine = InferenceEngine::new(config);
16510        let local = engine
16511            .unified_registry
16512            .all()
16513            .find(|schema| {
16514                schema.is_local() && schema.has_capability(ModelCapability::SpeechToText)
16515            })
16516            .cloned()
16517            .expect("built-in local STT model");
16518
16519        assert!(matches!(
16520            engine.admit_speech_candidate(&local, false),
16521            SpeechCandidateAdmission::SkipBlocked(_)
16522        ));
16523        assert!(matches!(
16524            engine.admit_speech_candidate(&local, true),
16525            SpeechCandidateAdmission::FailBlocked(InferenceError::LocalResourceBlocked { .. })
16526        ));
16527
16528        let mut os_owned = local;
16529        os_owned.id = "windows/speech-synthesis:test".into();
16530        os_owned.source = ModelSource::WindowsSpeech {};
16531        assert!(matches!(
16532            engine.admit_speech_candidate(&os_owned, true),
16533            SpeechCandidateAdmission::Proceed(None)
16534        ));
16535    }
16536
16537    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
16538    async fn cancelled_native_blocking_work_keeps_its_exact_charge_until_completion() {
16539        let coordinator = std::sync::Arc::new(resource_policy::LocalAdmissionCoordinator::new(
16540            resource_policy::ResourcePolicy::custom_gb(1.0).unwrap(),
16541            crate::hardware::HardwareInfo {
16542                os: "test".into(),
16543                arch: "test".into(),
16544                cpu_cores: 8,
16545                total_ram_mb: 32 * 1024,
16546                gpu_backend: crate::hardware::GpuBackend::Cpu,
16547                gpu_memory_mb: None,
16548                gpu_devices: Vec::new(),
16549                recommended_model: "fixture".into(),
16550                recommended_context: 4096,
16551                max_model_mb: 32 * 1024,
16552            },
16553        ));
16554        let reservation = coordinator
16555            .reserve_measured_host("detached-native-a", 512 * 1024 * 1024, 0)
16556            .unwrap();
16557        let lease = reservation.detached_lease();
16558        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
16559        let release =
16560            std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
16561        let release_worker = release.clone();
16562        let operation = tokio::spawn(async move {
16563            let _request_reservation = reservation;
16564            run_admitted_blocking(lease, move || {
16565                let _ = started_tx.send(());
16566                let (lock, ready) = &*release_worker;
16567                let mut released = lock
16568                    .lock()
16569                    .unwrap_or_else(std::sync::PoisonError::into_inner);
16570                while !*released {
16571                    released = ready
16572                        .wait(released)
16573                        .unwrap_or_else(std::sync::PoisonError::into_inner);
16574                }
16575            })
16576            .await
16577        });
16578        started_rx.await.unwrap();
16579        operation.abort();
16580        let _ = operation.await;
16581
16582        let blocked = coordinator.reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0);
16583        assert!(
16584            blocked.is_err(),
16585            "cancelling the await must not advertise memory still owned by spawn_blocking"
16586        );
16587
16588        let (lock, ready) = &*release;
16589        *lock
16590            .lock()
16591            .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
16592        ready.notify_one();
16593        tokio::time::timeout(std::time::Duration::from_secs(2), async {
16594            loop {
16595                if coordinator
16596                    .reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0)
16597                    .is_ok()
16598                {
16599                    break;
16600                }
16601                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
16602            }
16603        })
16604        .await
16605        .expect("the detached charge must clear after native work really exits");
16606    }
16607
16608    #[test]
16609    fn nonstream_native_text_generation_uses_cancellable_blocking_boundary() {
16610        let source = include_str!("lib.rs");
16611        let mlx = source
16612            .split("async fn generate_mlx(")
16613            .nth(1)
16614            .and_then(|tail| tail.split("async fn generate_local(").next())
16615            .expect("generate_mlx source");
16616        let local = source
16617            .split("async fn generate_local(")
16618            .nth(1)
16619            .and_then(|tail| tail.split("fn apply_top_k_top_p").next())
16620            .expect("generate_local source");
16621
16622        assert!(
16623            mlx.contains("run_admitted_blocking"),
16624            "native MLX decode must yield the Tokio runtime so a WS timeout/cancel can preempt its response waiter"
16625        );
16626        assert!(
16627            local.contains("run_admitted_blocking"),
16628            "polymorphic local decode must yield the Tokio runtime so a WS timeout/cancel can preempt its response waiter"
16629        );
16630    }
16631
16632    #[test]
16633    fn native_mlx_text_turns_prepare_the_backend_once() {
16634        let source = include_str!("lib.rs");
16635        let native_dispatch = source
16636            .split("// A local MLX checkpoint the dedicated Qwen `MlxBackend`")
16637            .nth(1)
16638            .and_then(|tail| tail.split("#[cfg(not(all(target_os = \"macos\"").next())
16639            .expect("native MLX dispatch branch");
16640        let before_images = native_dispatch
16641            .split("if has_images {")
16642            .next()
16643            .expect("text-only prefix");
16644        let image_setup = native_dispatch
16645            .split("if has_images {")
16646            .nth(1)
16647            .and_then(|tail| tail.split("let can_do_vision").next())
16648            .expect("image-only backend setup");
16649
16650        assert_eq!(
16651            before_images.matches("ensure_mlx_backend").count(),
16652            0,
16653            "text turns must reach backend preparation only through generate_mlx"
16654        );
16655        assert_eq!(
16656            image_setup.matches("ensure_mlx_backend").count(),
16657            1,
16658            "the eager capability probe belongs only to the image branch"
16659        );
16660        assert_eq!(
16661            native_dispatch.matches("ensure_mlx_backend").count(),
16662            1,
16663            "no second eager preparation may escape the image branch"
16664        );
16665    }
16666
16667    #[cfg(unix)]
16668    #[tokio::test]
16669    async fn cancelled_speech_subprocess_is_killed_and_reaped_before_charge_clears() {
16670        use std::os::unix::fs::PermissionsExt;
16671
16672        let fixture = TempDir::new().unwrap();
16673        let runtime = SpeechRuntime::new(fixture.path().join("speech-runtime"));
16674        std::fs::create_dir_all(runtime.stt_program.parent().unwrap()).unwrap();
16675        std::fs::write(
16676            &runtime.stt_program,
16677            b"#!/bin/sh\necho started > \"$1\"\nsleep 1\necho continued > \"$2\"\n",
16678        )
16679        .unwrap();
16680        let mut permissions = std::fs::metadata(&runtime.stt_program)
16681            .unwrap()
16682            .permissions();
16683        permissions.set_mode(0o755);
16684        std::fs::set_permissions(&runtime.stt_program, permissions).unwrap();
16685
16686        let coordinator = std::sync::Arc::new(resource_policy::LocalAdmissionCoordinator::new(
16687            resource_policy::ResourcePolicy::custom_gb(1.0).unwrap(),
16688            crate::hardware::HardwareInfo {
16689                total_ram_mb: 32 * 1024,
16690                ..crate::hardware::HardwareInfo::detect()
16691            },
16692        ));
16693        let reservation = coordinator
16694            .reserve_measured_host("mlx-audio-a", 512 * 1024 * 1024, 0)
16695            .unwrap();
16696        let lease = reservation.detached_lease();
16697        let started = fixture.path().join("started");
16698        let continued = fixture.path().join("continued");
16699        let args = vec![
16700            started.display().to_string(),
16701            continued.display().to_string(),
16702        ];
16703        let command = tokio::spawn(async move {
16704            let _request_reservation = reservation;
16705            run_mlx_audio_command(&runtime, "stt.generate", &args, lease).await
16706        });
16707        tokio::time::timeout(std::time::Duration::from_secs(2), async {
16708            while !started.exists() {
16709                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
16710            }
16711        })
16712        .await
16713        .expect("speech fixture child must start");
16714        command.abort();
16715        let _ = command.await;
16716
16717        tokio::time::timeout(std::time::Duration::from_secs(2), async {
16718            loop {
16719                if coordinator
16720                    .reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0)
16721                    .is_ok()
16722                {
16723                    break;
16724                }
16725                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
16726            }
16727        })
16728        .await
16729        .expect("charge clears only after the cancelled child is reaped");
16730        tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
16731        assert!(
16732            !continued.exists(),
16733            "the cancelled local speech process must not keep allocating in the background"
16734        );
16735    }
16736
16737    #[test]
16738    fn local_model_eviction_surface_includes_every_in_process_cache() {
16739        let source = include_str!("lib.rs");
16740        for cache in [
16741            "self.mlx_backends.evict_if_idle(model_id)",
16742            "self.local_backends.evict_if_idle(model_id)",
16743            "self.flux_cache.evict_if_idle(model_id)",
16744            "self.ltx_cache.evict_if_idle(model_id)",
16745            "self.kokoro_cache.evict_if_idle(model_id)",
16746            "self.local_backends.evict_idle()",
16747        ] {
16748            assert!(source.contains(cache), "missing eviction seam: {cache}");
16749        }
16750    }
16751
16752    /// Demand-driven credentials: constructing the real builtin catalog and
16753    /// rendering passive Home/Models/setup/speech/health surfaces must not
16754    /// touch any secret backend. The process-wide counter is the proof seam;
16755    /// a registry-local fake would miss provider helpers that construct their
16756    /// own `SecretStore`.
16757    #[tokio::test]
16758    async fn catalog_refresh_uses_authority_hints_without_secret_reads() {
16759        if !crate::run_in_isolated_test_process(
16760            "tests::catalog_refresh_uses_authority_hints_without_secret_reads",
16761            "CAR_CATALOG_ZERO_SECRET_READ_CHILD",
16762        ) {
16763            return;
16764        }
16765        let _environment = ENV_MUTEX.lock().await;
16766        let fixture = TempDir::new().unwrap();
16767        let credential_envs = [
16768            car_auth::PARSLEE_ACCESS_TOKEN_KEY,
16769            car_auth::PARSLEE_API_BASE_KEY,
16770            crate::openrouter::API_KEY_ENV,
16771            "OPENAI_API_KEY",
16772            "ANTHROPIC_API_KEY",
16773            "GOOGLE_API_KEY",
16774            "ELEVENLABS_API_KEY",
16775        ];
16776        let mut restored_names = vec![car_home::ENV_VAR, "CAR_SECRETS_FILE_DIR"];
16777        restored_names.extend(credential_envs);
16778        let _restore = RestoredEnvironment::capture(&restored_names);
16779        unsafe {
16780            std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
16781            std::env::set_var(
16782                "CAR_SECRETS_FILE_DIR",
16783                fixture.path().join("isolated-secrets"),
16784            );
16785            for name in credential_envs {
16786                std::env::remove_var(name);
16787            }
16788        }
16789
16790        let before = car_secrets::secret_store_activity();
16791        let engine = InferenceEngine::new(test_config(fixture.path().join("models")));
16792
16793        // Home / Models catalog.
16794        let listed = engine.list_models_unified();
16795        let schemas = engine.list_schemas();
16796        for provider in ["parslee", "openai", "anthropic", "google", "elevenlabs"] {
16797            assert!(
16798                schemas.iter().any(|schema| schema.provider == provider),
16799                "real builtin catalog lost the credential-bearing {provider} rows"
16800            );
16801        }
16802        assert!(
16803            schemas.iter().any(|schema| matches!(
16804                schema.source,
16805                ModelSource::RemoteApi {
16806                    protocol: ApiProtocol::OpenRouter,
16807                    ..
16808                }
16809            )),
16810            "real builtin catalog lost the reviewed OpenRouter rows"
16811        );
16812        assert_eq!(listed.len(), schemas.len());
16813
16814        // `models.setup_plan` delegates to this exact list + recommender path.
16815        let schema_refs: Vec<&ModelSchema> = schemas.iter().collect();
16816        let _setup_plan = crate::recommend(
16817            &schema_refs,
16818            &HardwareInfo::detect(),
16819            UseCase::default(),
16820            QualityTier::default(),
16821            Privacy::OnDevice,
16822        );
16823
16824        // Cached speech state plus Home/health status entry points.
16825        let _speech = engine.speech_health();
16826        let _concierge = engine.concierge_status(false).await;
16827        let _health = engine.model_health().await;
16828
16829        let after = car_secrets::secret_store_activity();
16830        assert_eq!(
16831            after, before,
16832            "passive builtin catalog surfaces performed secret-store operations"
16833        );
16834    }
16835
16836    #[tokio::test]
16837    async fn list_models_unified_and_model_health_are_zero_secret_store_probes() {
16838        if !crate::run_in_isolated_test_process(
16839            "tests::list_models_unified_and_model_health_are_zero_secret_store_probes",
16840            "CAR_DIRECT_MODEL_SURFACES_ZERO_SECRET_CHILD",
16841        ) {
16842            return;
16843        }
16844        let _environment = ENV_MUTEX.lock().await;
16845        let fixture = TempDir::new().unwrap();
16846        let _restore = RestoredEnvironment::capture(&[
16847            car_home::ENV_VAR,
16848            "CAR_SECRETS_FILE_DIR",
16849            crate::openrouter::API_KEY_ENV,
16850        ]);
16851        unsafe {
16852            std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
16853            std::env::set_var(
16854                "CAR_SECRETS_FILE_DIR",
16855                fixture.path().join("isolated-secrets"),
16856            );
16857            std::env::remove_var(crate::openrouter::API_KEY_ENV);
16858        }
16859        let engine = InferenceEngine::new(test_config(fixture.path().join("models")));
16860        let before = car_secrets::secret_store_activity();
16861
16862        let rows = engine.list_models_unified();
16863        let health = engine.model_health().await;
16864
16865        assert!(!rows.is_empty(), "the unified catalog fixture must exist");
16866        assert!(health.total_models > 0, "the health fixture must exist");
16867        let after = car_secrets::secret_store_activity();
16868        assert_eq!(after.status_attempts, before.status_attempts);
16869        assert_eq!(after.get_attempts, before.get_attempts);
16870    }
16871
16872    /// The signed-catalog cache holds an anti-rollback version counter and the
16873    /// discovery cache holds a provider model list — both are per-daemon
16874    /// bookkeeping, and both were derived from the *weights* dir, which
16875    /// deliberately stays machine-shared. A relocated daemon therefore kept
16876    /// writing them into the primary's `~/.car`, which is precisely the
16877    /// shared-state clobber `CAR_HOME` exists to prevent.
16878    ///
16879    /// The weights themselves must still NOT move, or every isolated daemon
16880    /// re-downloads tens of gigabytes to end up with identical bytes.
16881    #[test]
16882    fn car_home_moves_the_catalog_and_discovery_caches_but_never_the_weights() {
16883        let _environment = crate::openrouter::test_environment_scope();
16884        let prior = std::env::var_os(car_home::ENV_VAR);
16885
16886        unsafe { std::env::remove_var(car_home::ENV_VAR) };
16887        let shared = InferenceConfig::default();
16888        let default_catalog = crate::catalog::cache_path(&shared.state_root);
16889        let default_discovery = crate::discovery::cache_path(&shared.state_models_dir());
16890
16891        let alt = Path::new("/tmp/car-home-inference-cache-test");
16892        unsafe { std::env::set_var(car_home::ENV_VAR, alt) };
16893        let relocated = InferenceConfig::default();
16894        let catalog = crate::catalog::cache_path(&relocated.state_root);
16895        let discovery = crate::discovery::cache_path(&relocated.state_models_dir());
16896
16897        match prior {
16898            Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
16899            None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
16900        }
16901
16902        assert_eq!(catalog, alt.join(crate::catalog::CATALOG_CACHE_FILE));
16903        assert_eq!(
16904            discovery,
16905            alt.join("models")
16906                .join(crate::discovery::DISCOVERED_MODELS_FILE)
16907        );
16908        assert_ne!(
16909            catalog, default_catalog,
16910            "the catalog cache must not resolve back into the shared root",
16911        );
16912        assert_ne!(
16913            discovery, default_discovery,
16914            "the discovery cache must not resolve back into the shared root",
16915        );
16916
16917        assert_eq!(
16918            relocated.models_dir, shared.models_dir,
16919            "the weights cache is machine-global and must not follow CAR_HOME",
16920        );
16921        assert!(
16922            !relocated.models_dir.starts_with(alt),
16923            "the weights cache must not be dragged under the override",
16924        );
16925    }
16926
16927    #[derive(Clone, Copy)]
16928    enum CacheRoutingSurface {
16929        Generate,
16930        Stream,
16931    }
16932
16933    /// Exercise cache-aware pricing through the public tracked generation
16934    /// surfaces, including adaptive selection and a real mocked OpenRouter HTTP
16935    /// request. This deliberately does not call the scorer directly: a routing
16936    /// field that exists only in `RouteRequest` but is dropped by either
16937    /// production call path must make these tests fail.
16938    async fn invoke_cache_routed_openrouter(
16939        surface: CacheRoutingSurface,
16940        cache_read_estimate: usize,
16941        cache_write_estimate: usize,
16942    ) -> String {
16943        use wiremock::matchers::{method, path};
16944        use wiremock::{Mock, MockServer, ResponseTemplate};
16945
16946        let _credential_scope = crate::openrouter::test_credential_scope();
16947        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
16948        let server = MockServer::start().await;
16949        let response = match surface {
16950            CacheRoutingSurface::Generate => ResponseTemplate::new(200).set_body_json(
16951                serde_json::json!({
16952                    "choices": [{
16953                        "message": {"role": "assistant", "content": "cache-route-ok"},
16954                        "finish_reason": "stop"
16955                    }],
16956                    "usage": {"prompt_tokens": 40_000, "completion_tokens": 8}
16957                }),
16958            ),
16959            CacheRoutingSurface::Stream => ResponseTemplate::new(200).set_body_raw(
16960                concat!(
16961                    "data: {\"choices\":[{\"delta\":{\"content\":\"cache-route-ok\"},\"finish_reason\":\"stop\"}]}\n\n",
16962                    "data: [DONE]\n\n"
16963                ),
16964                "text/event-stream",
16965            ),
16966        };
16967        Mock::given(method("POST"))
16968            .and(path("/v1/chat/completions"))
16969            .respond_with(response)
16970            .mount(&server)
16971            .await;
16972
16973        let tmp = TempDir::new().unwrap();
16974        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16975        let mut uncached_cheap = crate::openrouter::curated_schemas()
16976            .into_iter()
16977            .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
16978            .unwrap();
16979        uncached_cheap.id = "openrouter/test/uncached-cheap".into();
16980        uncached_cheap.name = uncached_cheap.id.clone();
16981        uncached_cheap.cost = CostModel {
16982            input_per_mtok: Some(1.0),
16983            output_per_mtok: Some(1.0),
16984            cache_read_input_per_mtok: Some(100.0),
16985            cache_write_input_per_mtok: Some(100.0),
16986            ..Default::default()
16987        };
16988        if let ModelSource::RemoteApi { endpoint, .. } = &mut uncached_cheap.source {
16989            *endpoint = server.uri();
16990        }
16991
16992        let mut cached_cheap = uncached_cheap.clone();
16993        cached_cheap.id = "openrouter/test/cached-cheap".into();
16994        cached_cheap.name = cached_cheap.id.clone();
16995        cached_cheap.cost = CostModel {
16996            input_per_mtok: Some(80.0),
16997            output_per_mtok: Some(1.0),
16998            cache_read_input_per_mtok: Some(0.001),
16999            cache_write_input_per_mtok: Some(0.001),
17000            ..Default::default()
17001        };
17002
17003        let uncached_id = uncached_cheap.id.clone();
17004        let cached_id = cached_cheap.id.clone();
17005        engine
17006            .unified_registry
17007            .register_project_model(uncached_cheap);
17008        engine.unified_registry.register_project_model(cached_cheap);
17009        let exclude_models = engine
17010            .list_schemas()
17011            .into_iter()
17012            .map(|schema| schema.id)
17013            .filter(|id| id != &uncached_id && id != &cached_id)
17014            .collect();
17015
17016        // bytes/4 => 40K estimated prompt tokens. An explicit read estimate is
17017        // clamped to that footprint; zero remains an honest "no cache knowledge"
17018        // rather than being inferred from cache_control.
17019        let mut params = GenerateParams {
17020            max_tokens: 8,
17021            ..Default::default()
17022        };
17023        assert_eq!(params.estimated_cache_read_input_tokens, 0);
17024        assert_eq!(params.estimated_cache_write_input_tokens, 0);
17025        params.estimated_cache_read_input_tokens = cache_read_estimate;
17026        params.estimated_cache_write_input_tokens = cache_write_estimate;
17027        let req = GenerateRequest {
17028            prompt: "x".repeat(160_000),
17029            params,
17030            cache_control: true,
17031            intent: Some(IntentHint {
17032                prefer_quality: true,
17033                exclude_models,
17034                ..Default::default()
17035            }),
17036            ..Default::default()
17037        };
17038
17039        match surface {
17040            CacheRoutingSurface::Generate => {
17041                engine
17042                    .generate_tracked(req)
17043                    .await
17044                    .expect("mocked OpenRouter generation should succeed")
17045                    .model_used
17046            }
17047            CacheRoutingSurface::Stream => {
17048                let mut handle = engine
17049                    .generate_tracked_stream(req)
17050                    .await
17051                    .expect("mocked OpenRouter stream should start");
17052                let selected = handle.model_used.clone();
17053                while handle.events.recv().await.is_some() {}
17054                selected
17055            }
17056        }
17057    }
17058
17059    #[tokio::test(flavor = "current_thread")]
17060    async fn tracked_generate_uses_explicit_cache_estimate_and_defaults_to_zero() {
17061        let without_estimate =
17062            invoke_cache_routed_openrouter(CacheRoutingSurface::Generate, 0, 0).await;
17063        let with_estimate =
17064            invoke_cache_routed_openrouter(CacheRoutingSurface::Generate, 40_000, 0).await;
17065        assert_eq!(without_estimate, "openrouter/test/uncached-cheap");
17066        assert_eq!(with_estimate, "openrouter/test/cached-cheap");
17067    }
17068
17069    #[tokio::test(flavor = "current_thread")]
17070    async fn tracked_stream_uses_explicit_cache_estimate_and_defaults_to_zero() {
17071        let without_estimate =
17072            invoke_cache_routed_openrouter(CacheRoutingSurface::Stream, 0, 0).await;
17073        let with_estimate =
17074            invoke_cache_routed_openrouter(CacheRoutingSurface::Stream, 0, 40_000).await;
17075        assert_eq!(without_estimate, "openrouter/test/uncached-cheap");
17076        assert_eq!(with_estimate, "openrouter/test/cached-cheap");
17077    }
17078
17079    #[tokio::test(flavor = "current_thread")]
17080    async fn authenticated_openrouter_registry_stays_static_and_rejects_unknown_ids() {
17081        let _credential_scope = crate::openrouter::test_credential_scope();
17082        crate::openrouter::set_test_credential(Some("static-key"));
17083        let tmp = TempDir::new().unwrap();
17084        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17085        let personal: Vec<_> = engine
17086            .list_schemas()
17087            .into_iter()
17088            .filter(|schema| schema.id.starts_with("openrouter/"))
17089            .collect();
17090        assert_eq!(personal.len(), crate::openrouter::curated_model_count());
17091        assert!(personal.iter().all(ModelSchema::available_now));
17092        assert!(personal
17093            .iter()
17094            .all(|schema| schema.trust_tier == TrustTier::Curated));
17095        assert!(personal
17096            .iter()
17097            .all(|schema| !schema.tags.iter().any(|tag| tag == "dynamic")));
17098
17099        for unknown in [
17100            "openrouter/vendor/brand-new-model",
17101            "openrouter/openai/gpt-5.4-typo",
17102        ] {
17103            assert!(engine
17104                .list_schemas()
17105                .iter()
17106                .all(|schema| schema.id != unknown));
17107            assert_eq!(engine.model_context_window(unknown), 0);
17108            let error = engine
17109                .generate_tracked(GenerateRequest {
17110                    prompt: "must fail before transport".into(),
17111                    model: Some(unknown.into()),
17112                    params: GenerateParams {
17113                        strict_model: true,
17114                        ..Default::default()
17115                    },
17116                    ..Default::default()
17117                })
17118                .await
17119                .expect_err("unregistered personal OpenRouter ids must not reach inference");
17120            assert!(
17121                matches!(&error, InferenceError::ModelNotFound(id) if id == unknown),
17122                "{unknown}: {error}"
17123            );
17124            let stream_error = match engine
17125                .generate_tracked_stream(GenerateRequest {
17126                    prompt: "must fail before stream transport".into(),
17127                    model: Some(unknown.into()),
17128                    ..Default::default()
17129                })
17130                .await
17131            {
17132                Ok(_) => panic!("unregistered ids must also fail before streaming"),
17133                Err(error) => error,
17134            };
17135            assert!(
17136                matches!(&stream_error, InferenceError::ModelNotFound(id) if id == unknown),
17137                "{unknown}: {stream_error}"
17138            );
17139        }
17140    }
17141
17142    #[tokio::test(flavor = "current_thread")]
17143    async fn static_openrouter_rows_participate_in_adaptive_routing_only_with_a_key() {
17144        let (_credential_scope, _provider_env) =
17145            crate::openrouter::test_credential_and_environment_scope_async().await;
17146        let tmp = TempDir::new().unwrap();
17147        crate::openrouter::set_test_credential(Some("static-key"));
17148        unsafe {
17149            std::env::set_var("CAR_STATIC_ROUTING_PEER_KEY", "peer-key");
17150        }
17151        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17152        let mut peer = remote_stream_fixture_schema(
17153            "test/openai-routing-peer",
17154            "http://127.0.0.1:9".into(),
17155            schema::ApiProtocol::OpenAiCompat,
17156            "CAR_STATIC_ROUTING_PEER_KEY",
17157        );
17158        peer.provider = "openai".into();
17159        peer.trust_tier = TrustTier::Curated;
17160        engine.unified_registry.register_project_model(peer);
17161        let reviewed: std::collections::HashSet<_> = engine
17162            .list_schemas()
17163            .into_iter()
17164            .filter(|schema| schema.id.starts_with("openrouter/"))
17165            .map(|schema| schema.id)
17166            .collect();
17167        assert_eq!(reviewed.len(), crate::openrouter::curated_model_count());
17168
17169        let with_key = engine
17170            .route_adaptive_with_intent(
17171                "Answer this simple question cheaply.",
17172                Some(IntentHint::default()),
17173            )
17174            .await;
17175        let openrouter_candidates: Vec<_> = std::iter::once(with_key.model_id.as_str())
17176            .chain(
17177                with_key
17178                    .candidates
17179                    .iter()
17180                    .map(|candidate| candidate.model_id.as_str()),
17181            )
17182            .chain(with_key.fallbacks.iter().map(String::as_str))
17183            .filter(|id| id.starts_with("openrouter/"))
17184            .collect();
17185        assert!(
17186            !openrouter_candidates.is_empty(),
17187            "keyed adaptive decision must include a reviewed OpenRouter row: {with_key:?}"
17188        );
17189        assert!(openrouter_candidates
17190            .iter()
17191            .all(|id| reviewed.contains(*id)));
17192        assert!(
17193            std::iter::once(with_key.model_id.as_str())
17194                .chain(with_key.fallbacks.iter().map(String::as_str),)
17195                .any(|id| !id.starts_with("openrouter/")),
17196            "fallback chain must retain cross-provider alternatives: {with_key:?}"
17197        );
17198
17199        crate::openrouter::set_test_credential(None);
17200        let without_key = engine
17201            .route_adaptive_with_intent(
17202                "Answer this simple question cheaply.",
17203                Some(IntentHint::default()),
17204            )
17205            .await;
17206        assert!(!std::iter::once(without_key.model_id.as_str())
17207            .chain(
17208                without_key
17209                    .candidates
17210                    .iter()
17211                    .map(|candidate| candidate.model_id.as_str()),
17212            )
17213            .chain(without_key.fallbacks.iter().map(String::as_str))
17214            .any(|id| id.starts_with("openrouter/")));
17215        unsafe {
17216            std::env::remove_var("CAR_STATIC_ROUTING_PEER_KEY");
17217        }
17218    }
17219
17220    #[tokio::test(flavor = "current_thread")]
17221    async fn v2_parslee_auth_drives_managed_registration_routing_lane_and_logout() {
17222        if !crate::run_in_isolated_test_process(
17223            "tests::v2_parslee_auth_drives_managed_registration_routing_lane_and_logout",
17224            "CAR_V2_ROUTING_AUTH_CHILD",
17225        ) {
17226            return;
17227        }
17228        let tmp = TempDir::new().unwrap();
17229        let (_credential_scope, _provider_env) =
17230            crate::openrouter::test_credential_and_environment_scope_async().await;
17231        let _restore = RestoredEnvironment::capture(&[
17232            "CAR_SECRETS_FILE_DIR",
17233            car_auth::PARSLEE_ACCESS_TOKEN_KEY,
17234        ]);
17235        // Same reason as the legacy sibling below: the managed-row assertions
17236        // read the durable gateway (Parslee-ai/car#786) and credential
17237        // (Parslee-ai/car#887) verdicts out of the CAR state root, and a live
17238        // one left there by any other process suppresses every `parslee/*` row
17239        // no matter what this test seeds. Pin the root and forget the
17240        // in-process copies rather than inheriting whatever the machine holds.
17241        let _home = crate::openrouter::StateRootScope::new();
17242        crate::openrouter::clear_gateway_unconfigured();
17243        crate::parslee_credential::clear_credential_rejected();
17244        let secrets_dir = tmp.path().join("secrets");
17245        unsafe {
17246            std::env::set_var("CAR_SECRETS_FILE_DIR", &secrets_dir);
17247            std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
17248        }
17249        crate::openrouter::set_test_credential(Some("personal-openrouter-key"));
17250
17251        let store = car_secrets::SecretStore::new();
17252        let state_ref =
17253            car_secrets::SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
17254        store
17255            .publish(
17256                &state_ref,
17257                &serde_json::json!({
17258                    "schema": 2,
17259                    "revision": 7,
17260                    "generation": 3,
17261                    "active": {
17262                        "account_id": "account-v2",
17263                        "access_token": "v2-access",
17264                        "refresh_token": "v2-refresh",
17265                        "expires_at": 9_999_999_999_u64,
17266                        "api_base": "https://api.parslee.ai"
17267                    },
17268                    "accounts": [{
17269                        "account_id": "account-v2",
17270                        "access_token": "v2-access",
17271                        "refresh_token": "v2-refresh",
17272                        "expires_at": 9_999_999_999_u64,
17273                        "api_base": "https://api.parslee.ai"
17274                    }],
17275                    "tombstone": false
17276                })
17277                .to_string(),
17278            )
17279            .unwrap();
17280        assert_eq!(car_auth::access_token().as_deref(), Some("v2-access"));
17281        assert!(
17282            !store
17283                .status(&car_secrets::SecretRef::with_default_service(
17284                    car_auth::PARSLEE_ACCESS_TOKEN_KEY,
17285                ))
17286                .unwrap()
17287                .exists,
17288            "the regression must exercise V2 state without the import-only legacy slot"
17289        );
17290
17291        let managed_ids = [
17292            "parslee/openrouter/frontier-general",
17293            "parslee/openrouter/balanced-general",
17294        ];
17295        let personal_id = "openrouter/deepseek/deepseek-v3.2";
17296        let rogue_id = "community/custom-oauth";
17297        let schemas = crate::openrouter::curated_schemas();
17298        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("registry-models"));
17299        for id in managed_ids.into_iter().chain(std::iter::once(personal_id)) {
17300            registry.register_project_model(
17301                schemas
17302                    .iter()
17303                    .find(|schema| schema.id == id)
17304                    .unwrap_or_else(|| panic!("missing curated schema {id}"))
17305                    .clone(),
17306            );
17307        }
17308        registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
17309        for id in managed_ids {
17310            assert!(
17311                registry.get(id).unwrap().available_now(),
17312                "{id} must be available immediately when registered from V2 auth"
17313            );
17314        }
17315        assert!(registry.get(personal_id).unwrap().available_now());
17316        let mut rogue = schemas
17317            .iter()
17318            .find(|schema| schema.id == managed_ids[0])
17319            .unwrap()
17320            .clone();
17321        rogue.id = rogue_id.into();
17322        rogue.provider = "community".into();
17323        if let ModelSource::Proprietary {
17324            provider, endpoint, ..
17325        } = &mut rogue.source
17326        {
17327            *provider = "community".into();
17328            *endpoint = "https://untrusted.example".into();
17329        } else {
17330            panic!("managed fixture must remain proprietary");
17331        }
17332        registry.register(rogue);
17333        assert!(
17334            !registry.get(rogue_id).unwrap().available_now(),
17335            "a non-Parslee OAuth schema must not inherit Parslee V2 availability"
17336        );
17337
17338        registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
17339        for id in managed_ids {
17340            assert!(
17341                registry.get(id).unwrap().available_now(),
17342                "{id} must stay available in a refreshed V2-auth snapshot"
17343            );
17344        }
17345        assert!(
17346            !registry.get(rogue_id).unwrap().available_now(),
17347            "refresh must keep non-Parslee OAuth schemas unavailable"
17348        );
17349
17350        let router = AdaptiveRouter::new(
17351            crate::hardware::HardwareInfo::detect(),
17352            RoutingConfig {
17353                prefer_local: false,
17354                prior_strength: 1_000_000.0,
17355                quality_first_cold_start: false,
17356                ..RoutingConfig::default()
17357            },
17358        );
17359        let tracker = OutcomeTracker::new();
17360        let intent = IntentHint {
17361            task: Some(crate::intent::TaskHint::Chat),
17362            exclude_models: vec![personal_id.into()],
17363            ..Default::default()
17364        };
17365        let decision = router.route_with(crate::adaptive_router::RouteRequest {
17366            intent: Some(&intent),
17367            ..crate::adaptive_router::RouteRequest::new(
17368                "Explain this architecture.",
17369                &registry,
17370                &tracker,
17371            )
17372        });
17373        assert!(
17374            managed_ids.contains(&decision.model_id.as_str()),
17375            "managed V2-auth alias must be selectable: {decision:?}"
17376        );
17377        assert!(
17378            decision
17379                .candidates
17380                .iter()
17381                .any(|candidate| managed_ids.contains(&candidate.model_id.as_str())),
17382            "managed V2-auth alias must appear in adaptive candidates: {decision:?}"
17383        );
17384        assert!(
17385            decision
17386                .fallbacks
17387                .iter()
17388                .any(|id| managed_ids.contains(&id.as_str())),
17389            "managed V2-auth alias must appear in adaptive fallbacks: {decision:?}"
17390        );
17391        assert!(
17392            !std::iter::once(decision.model_id.as_str())
17393                .chain(
17394                    decision
17395                        .candidates
17396                        .iter()
17397                        .map(|candidate| candidate.model_id.as_str())
17398                )
17399                .chain(decision.fallbacks.iter().map(String::as_str))
17400                .any(|id| id == rogue_id),
17401            "a non-Parslee OAuth schema must never enter adaptive selection, candidates, or fallbacks: {decision:?}"
17402        );
17403
17404        let engine = InferenceEngine::new(test_config(tmp.path().join("engine-models")));
17405        let managed_lane_id = managed_ids[0];
17406        engine.lane_defaults_cache.write().unwrap().set(
17407            None,
17408            crate::intent::UseCase::Assistant,
17409            managed_lane_id.into(),
17410            1,
17411        );
17412        let request = GenerateRequest {
17413            prompt: "lane default".into(),
17414            intent: Some(IntentHint {
17415                task: Some(crate::intent::TaskHint::Chat),
17416                ..Default::default()
17417            }),
17418            ..Default::default()
17419        };
17420        assert_eq!(
17421            engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
17422            Some(managed_lane_id.to_string()),
17423            "a managed lane default must resolve from authoritative V2 auth"
17424        );
17425
17426        car_auth::logout()
17427            .await
17428            .expect("production logout must publish a signed-out tombstone");
17429        let persisted_logout: serde_json::Value =
17430            serde_json::from_str(&store.get(&state_ref).unwrap()).unwrap();
17431        assert_eq!(persisted_logout["tombstone"], true);
17432        assert_eq!(persisted_logout["accounts"], serde_json::json!([]));
17433        assert!(persisted_logout.get("active").is_none());
17434        assert_eq!(car_auth::access_token(), None);
17435
17436        registry.refresh_routing_availability(None, true);
17437        for id in managed_ids {
17438            assert!(
17439                !registry.get(id).unwrap().available_now(),
17440                "{id} must disappear from routing after the signed-out tombstone"
17441            );
17442        }
17443        assert!(
17444            registry.get(personal_id).unwrap().available_now(),
17445            "personal rows must continue to follow their independent personal-key seam"
17446        );
17447
17448        let signed_out = engine.routing_registry_snapshot().await;
17449        assert!(
17450            signed_out
17451                .list()
17452                .into_iter()
17453                .filter(|schema| schema.id.starts_with("parslee/openrouter/"))
17454                .all(|schema| !schema.available_now()),
17455            "the next engine snapshot must exclude every managed alias after logout"
17456        );
17457        assert!(
17458            signed_out.get(personal_id).unwrap().available_now(),
17459            "logout must not disable a still-keyed personal OpenRouter row"
17460        );
17461        assert_eq!(
17462            engine.lane_pin_for(&request, &signed_out),
17463            None,
17464            "a signed-out managed lane default must stop resolving"
17465        );
17466
17467        crate::openrouter::set_test_credential(None);
17468        registry.refresh_availability();
17469        assert!(
17470            !registry.get(personal_id).unwrap().available_now(),
17471            "personal row availability must still turn off with the personal-key seam"
17472        );
17473    }
17474
17475    #[tokio::test(flavor = "current_thread")]
17476    async fn legacy_parslee_auth_is_routable_only_until_v2_tombstone_then_migrates() {
17477        if !crate::run_in_isolated_test_process(
17478            "tests::legacy_parslee_auth_is_routable_only_until_v2_tombstone_then_migrates",
17479            "CAR_LEGACY_V2_MIGRATION_CHILD",
17480        ) {
17481            return;
17482        }
17483        let tmp = TempDir::new().unwrap();
17484        let _provider_env = crate::openrouter::test_environment_scope_async().await;
17485        let _restore = RestoredEnvironment::capture(&[
17486            "CAR_SECRETS_FILE_DIR",
17487            car_home::ENV_VAR,
17488            car_auth::PARSLEE_ACCESS_TOKEN_KEY,
17489        ]);
17490        // The managed-alias assertions below also read two DURABLE
17491        // session-scoped verdicts — `gateway-state.json` (Parslee-ai/car#786)
17492        // and `parslee-credential-state.json` (Parslee-ai/car#887) — either of
17493        // which suppresses every `parslee/openrouter/*` row regardless of what
17494        // this test seeded into its secret store.
17495        //
17496        // Until Parslee-ai/car#986 this test was repaired by a side effect it
17497        // never asked for: the tombstoned half's `refresh_availability` ran
17498        // signed out and cleared BOTH verdicts, in memory and on disk, before
17499        // the legacy half looked at them. Construction is not a statement about
17500        // the session, so that clear is gone — and with it the accidental
17501        // repair. Isolate the state root into this test's own temp dir and
17502        // forget the in-process copies, so the only verdicts in play are the
17503        // ones this test set itself.
17504        let _home = crate::openrouter::StateRootScope::new();
17505        crate::openrouter::clear_gateway_unconfigured();
17506        crate::parslee_credential::clear_credential_rejected();
17507        unsafe {
17508            std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
17509            std::env::set_var(car_home::ENV_VAR, tmp.path().join("car-home"));
17510        }
17511
17512        let secret = |key| car_secrets::SecretRef::with_default_service(key);
17513        let seed_legacy = |store: &car_secrets::SecretStore| {
17514            store
17515                .put(
17516                    &secret(car_secrets::PARSLEE_ACCESS_TOKEN_KEY),
17517                    "legacy-access",
17518                )
17519                .unwrap();
17520            store
17521                .put(
17522                    &secret(car_secrets::PARSLEE_ACTIVE_ACCOUNT_ID_KEY),
17523                    "legacy-account",
17524                )
17525                .unwrap();
17526            store.put(
17527                &secret(car_secrets::PARSLEE_ACCOUNTS_KEY),
17528                r#"{"active":"legacy-account","accounts":[{"id":"legacy-account","email":"legacy@example.test"}]}"#,
17529            )
17530            .unwrap();
17531        };
17532        let state_ref = secret(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
17533        let managed = crate::openrouter::curated_schemas()
17534            .into_iter()
17535            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
17536            .unwrap();
17537
17538        unsafe {
17539            std::env::set_var("CAR_SECRETS_FILE_DIR", tmp.path().join("tombstone-secrets"));
17540        }
17541        let tombstoned_store = car_secrets::SecretStore::new();
17542        seed_legacy(&tombstoned_store);
17543        tombstoned_store
17544            .publish(
17545                &state_ref,
17546                r#"{"schema":2,"revision":1,"generation":1,"accounts":[],"tombstone":true}"#,
17547            )
17548            .unwrap();
17549        let mut tombstoned_registry =
17550            UnifiedRegistry::new_empty(tmp.path().join("tombstoned-models"));
17551        tombstoned_registry.register_project_model(managed.clone());
17552        assert!(
17553            !tombstoned_registry
17554                .get(&managed.id)
17555                .unwrap()
17556                .available_now(),
17557            "passive catalog registration must remain disabled without a configured hint"
17558        );
17559        let tombstoned = car_auth::resolve_credential(car_auth::CredentialReadMode::Use)
17560            .await
17561            .unwrap();
17562        assert!(tombstoned.is_none(), "the V2 tombstone is authoritative");
17563        tombstoned_registry.refresh_routing_availability(None, true);
17564        assert!(!tombstoned_registry
17565            .get(&managed.id)
17566            .unwrap()
17567            .available_now());
17568
17569        unsafe {
17570            std::env::set_var(
17571                "CAR_SECRETS_FILE_DIR",
17572                tmp.path().join("legacy-only-secrets"),
17573            );
17574        }
17575        let legacy_store = car_secrets::SecretStore::new();
17576        seed_legacy(&legacy_store);
17577        assert!(!legacy_store.status(&state_ref).unwrap().exists);
17578        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("legacy-models"));
17579        registry.register_project_model(managed.clone());
17580        assert!(
17581            !registry.get(&managed.id).unwrap().available_now(),
17582            "passive registration must not inspect attributable legacy slots"
17583        );
17584        registry.refresh_availability();
17585        assert!(
17586            !registry.get(&managed.id).unwrap().available_now(),
17587            "passive refresh must remain secret-store free until request-time migration"
17588        );
17589        let resolved = car_auth::resolve_credential(car_auth::CredentialReadMode::Use)
17590            .await
17591            .unwrap()
17592            .expect("request-time auth reconciliation must migrate attributable legacy state");
17593        registry.refresh_routing_availability(Some(&resolved.api_base), false);
17594        assert!(registry.get(&managed.id).unwrap().available_now());
17595        assert!(
17596            legacy_store.status(&state_ref).unwrap().exists,
17597            "request-time auth reconciliation must publish the migrated V2 record"
17598        );
17599        assert!(
17600            !legacy_store
17601                .status(&secret(car_secrets::PARSLEE_ACCESS_TOKEN_KEY))
17602                .unwrap()
17603                .exists,
17604            "successful V2 migration must clean the legacy access slot"
17605        );
17606    }
17607
17608    #[tokio::test]
17609    async fn reviewed_openrouter_lane_default_tracks_live_credential_availability() {
17610        let _credential_scope = crate::openrouter::test_credential_scope();
17611        crate::openrouter::set_test_credential(Some("static-key"));
17612        let tmp = TempDir::new().unwrap();
17613        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17614        let reviewed_id = "openrouter/deepseek/deepseek-v3.2";
17615        engine.lane_defaults_cache.write().unwrap().set(
17616            None,
17617            crate::intent::UseCase::Assistant,
17618            reviewed_id.into(),
17619            1,
17620        );
17621        let request = GenerateRequest {
17622            prompt: "lane default".into(),
17623            intent: Some(IntentHint {
17624                task: Some(crate::intent::TaskHint::Chat),
17625                ..Default::default()
17626            }),
17627            ..Default::default()
17628        };
17629        assert_eq!(
17630            engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
17631            Some(reviewed_id.to_string()),
17632            "a reviewed keyed row must be eligible as a lane default"
17633        );
17634        crate::openrouter::set_test_credential(None);
17635        assert_eq!(
17636            engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
17637            None,
17638            "the same static lane default must stop being eligible immediately after key removal"
17639        );
17640    }
17641
17642    #[tokio::test(flavor = "current_thread")]
17643    async fn personal_openrouter_baseline_remains_visible_but_disabled_without_a_credential() {
17644        let _credential_scope = crate::openrouter::test_credential_scope();
17645        crate::openrouter::set_test_credential(None);
17646        let tmp = TempDir::new().unwrap();
17647        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17648
17649        let personal: Vec<_> = engine
17650            .list_schemas()
17651            .into_iter()
17652            .filter(|schema| schema.id.starts_with("openrouter/"))
17653            .collect();
17654        assert_eq!(
17655            personal.len(),
17656            crate::openrouter::curated_model_count(),
17657            "the vetted personal rows stay discoverable as a disabled baseline"
17658        );
17659        assert!(
17660            personal.iter().all(|schema| !schema.available_now()),
17661            "no-key baseline rows must never become routing candidates"
17662        );
17663
17664        let error = engine
17665            .generate_tracked(GenerateRequest {
17666                prompt: "hello".into(),
17667                model: Some("openrouter/openai/gpt-5.4".into()),
17668                params: GenerateParams {
17669                    strict_model: true,
17670                    ..Default::default()
17671                },
17672                ..Default::default()
17673            })
17674            .await
17675            .expect_err("explicitly selecting a disabled baseline row must be actionable");
17676        let message = error.to_string();
17677        assert!(
17678            message.contains("car keys set openrouter") && message.contains("CarHost"),
17679            "disabled personal row must explain how to connect OpenRouter: {message}"
17680        );
17681    }
17682
17683    #[tokio::test]
17684    async fn reviewed_openrouter_metadata_stays_static_across_credential_changes() {
17685        let _credential_scope = crate::openrouter::test_credential_scope();
17686        crate::openrouter::set_test_credential(Some("static-key"));
17687
17688        let tmp = TempDir::new().unwrap();
17689        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17690        let live = engine.routing_registry_snapshot().await;
17691        let vetted = live.get("openrouter/openai/gpt-5.4").unwrap();
17692        assert!(vetted.available_now());
17693        assert_eq!(vetted.context_length, 1_050_000);
17694        assert_eq!(vetted.max_output_tokens, Some(128_000));
17695        assert_eq!(vetted.cost.input_per_mtok, Some(2.5));
17696        assert_eq!(vetted.cost.output_per_mtok, Some(15.0));
17697        assert!(
17698            !vetted.cost.pricing_tiers.is_empty(),
17699            "reviewed high-context pricing tiers must remain in the static row"
17700        );
17701        assert_eq!(vetted.trust_tier, TrustTier::Curated);
17702        assert!(vetted.tags.iter().any(|tag| tag == "frontier"));
17703        assert!(vetted.has_capability(ModelCapability::Code));
17704        assert!(vetted
17705            .supported_params
17706            .contains(&schema::GenerateParam::ExtendedThinking));
17707
17708        assert!(live.get("openrouter/vendor/unreviewed-model").is_none());
17709
17710        crate::openrouter::set_test_credential(None);
17711        let reverted = engine.routing_registry_snapshot().await;
17712        assert!(reverted.get("openrouter/vendor/unreviewed-model").is_none());
17713        let baseline = reverted.get("openrouter/openai/gpt-5.4").unwrap();
17714        assert!(
17715            !baseline.available_now(),
17716            "credential removal must disable the reviewed row without removing it"
17717        );
17718    }
17719
17720    /// Parslee-ai/car#651 — a model pulled against a **running** daemon must
17721    /// count as ready without a restart.
17722    ///
17723    /// `weights_ready` is assigned in `UnifiedRegistry::register`, and the
17724    /// daemon's engine is a `get_or_init` singleton behind an `Arc` with no
17725    /// interior mutability on the registry — so if that assignment were the
17726    /// only one, the flag would be frozen to the on-disk state at boot for the
17727    /// daemon's lifetime. The `require_ready` filter would keep skipping a
17728    /// freshly-pulled model, the soft fallback would drop `require_ready`
17729    /// entirely, and the remedy the #638 timeout message prints (`car models
17730    /// pull <id>`) would do nothing until a restart.
17731    ///
17732    /// It isn't the only one: `refresh_availability` recomputes `weights_ready`
17733    /// too, and every routing and listing entry point goes through
17734    /// `routing_registry_snapshot` (clone + refresh) rather than the frozen
17735    /// registry. This pins that contract from the outside — through the engine
17736    /// surface, with no `&mut` and no re-registration, exactly as the daemon
17737    /// holds it. `list_schemas` reads the same snapshot the router filters on.
17738    #[test]
17739    fn model_pulled_at_runtime_is_ready_without_a_daemon_restart() {
17740        let tmp = TempDir::new().unwrap();
17741        let models_dir = tmp.path().join("models");
17742        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
17743        engine.register_model(ModelSchema {
17744            id: "mlx/pulled-later".into(),
17745            name: "PulledLater".into(),
17746            provider: "local".into(),
17747            family: "qwen3".into(),
17748            version: "test".into(),
17749            capabilities: vec![ModelCapability::Generate, ModelCapability::Code],
17750            context_length: 4096,
17751            max_output_tokens: None,
17752            param_count: String::new(),
17753            quantization: None,
17754            performance: schema::PerformanceEnvelope::default(),
17755            cost: schema::CostModel::default(),
17756            source: ModelSource::Mlx {
17757                hf_repo: "example/pulled-later".into(),
17758                hf_weight_file: None,
17759            },
17760            tags: vec![],
17761            supported_params: vec![],
17762            public_benchmarks: vec![],
17763            trust_tier: TrustTier::Curated,
17764            deprecated: false,
17765            available: true,
17766            weights_ready: false,
17767        });
17768
17769        // From here on the engine is shared-immutable — the daemon regime.
17770        let engine = &engine;
17771        let is_ready = || {
17772            engine
17773                .list_schemas()
17774                .into_iter()
17775                .find(|s| s.id == "mlx/pulled-later")
17776                .expect("registered model must be listed")
17777                .weights_ready
17778        };
17779
17780        assert!(!is_ready(), "precondition: no weights on disk yet");
17781
17782        // `car models pull` against the running daemon: weights land on disk
17783        // via `ensure_local` (&self), and nothing re-registers the schema.
17784        let dir = models_dir.join("PulledLater");
17785        std::fs::create_dir_all(&dir).unwrap();
17786        std::fs::write(dir.join("config.json"), "{}").unwrap();
17787        std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
17788
17789        assert!(
17790            is_ready(),
17791            "a model pulled at runtime must be ready without restarting the daemon"
17792        );
17793
17794        // And the inverse, so this can't pass on a flag that is merely stuck
17795        // true: weights removed out from under a live daemon stop being ready.
17796        std::fs::remove_file(dir.join("model.safetensors")).unwrap();
17797        assert!(
17798            !is_ready(),
17799            "readiness must track the disk in both directions, not latch"
17800        );
17801    }
17802
17803    #[tokio::test(flavor = "current_thread")]
17804    async fn missing_openrouter_key_is_typed_without_changing_its_string_contract() {
17805        // Both process-global guards must use the shared credential→environment
17806        // order. This test used to take them in reverse while the static-row
17807        // test took the canonical order, deadlocking the whole libtest binary.
17808        let (_credential_scope, _provider_environment) =
17809            crate::openrouter::test_credential_and_environment_scope_async().await;
17810        let home = crate::openrouter::StateRootScope::new();
17811        let _restore = RestoredEnvironment::capture(&["CAR_SECRETS_FILE_DIR"]);
17812        unsafe {
17813            std::env::set_var("CAR_SECRETS_FILE_DIR", home.path().join("secrets"));
17814        }
17815
17816        // This override is consulted before env/keychain resolution and forces
17817        // the exact no-key state, so the regression cannot inspect the real
17818        // environment or keychain.
17819        crate::openrouter::set_test_credential(None);
17820
17821        let models_dir = home.path().join("models");
17822        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
17823        let schema = crate::openrouter::curated_schemas()
17824            .into_iter()
17825            .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
17826            .expect("personal OpenRouter fixture");
17827        let model_id = schema.id.clone();
17828        engine.unified_registry.register_project_model(schema);
17829
17830        let error = engine
17831            .generate_tracked(GenerateRequest {
17832                prompt: "must stop before provider dispatch".into(),
17833                model: Some(model_id.clone()),
17834                params: GenerateParams {
17835                    strict_model: true,
17836                    ..Default::default()
17837                },
17838                ..Default::default()
17839            })
17840            .await
17841            .expect_err("an OpenRouter route without a key must fail before dispatch");
17842
17843        let tracker = engine.outcome_tracker();
17844        assert!(
17845            tracker.read().await.profile(&model_id).is_none(),
17846            "a missing OpenRouter key must not create model health"
17847        );
17848        let mut receipts = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
17849        receipts.extend(tracker.write().await.drain_ledger());
17850        let receipts = receipts
17851            .iter()
17852            .filter(|receipt| receipt.model_id == model_id)
17853            .collect::<Vec<_>>();
17854        assert_eq!(receipts.len(), 1);
17855        assert_eq!(receipts[0].success, None);
17856        assert_eq!(receipts[0].quality, None);
17857        assert!(
17858            engine
17859                .adaptive_router
17860                .circuit_breakers
17861                .lock()
17862                .unwrap()
17863                .state(&model_id)
17864                .is_none(),
17865            "a missing OpenRouter key must not create breaker state"
17866        );
17867
17868        let message = "OpenRouter requires a key — run `car keys set openrouter` or connect your OpenRouter account in CarHost";
17869        assert_eq!(
17870            error.to_string(),
17871            format!("inference failed: {message}"),
17872            "guard: the pre-existing Display contract must stay byte-identical"
17873        );
17874        assert!(
17875            !is_auth_failure_message(&error.to_string()),
17876            "guard: an OpenRouter key is not repaired by Parslee sign-in"
17877        );
17878        assert_eq!(
17879            classify_fallback_reason(&error),
17880            classify_fallback_reason(&InferenceError::InferenceFailed(message.into())),
17881            "retyping must preserve prior fallback classification"
17882        );
17883        match error {
17884            InferenceError::ProviderKeyMissing {
17885                provider,
17886                model,
17887                env_vars,
17888                message: actual_message,
17889            } => {
17890                assert_eq!(provider, "openrouter");
17891                assert_eq!(model, model_id);
17892                assert_eq!(env_vars, vec![crate::openrouter::API_KEY_ENV]);
17893                assert_eq!(actual_message, message);
17894            }
17895            other => panic!("expected ProviderKeyMissing, got {other:?}"),
17896        }
17897    }
17898
17899    #[tokio::test(flavor = "current_thread")]
17900    async fn missing_generic_remote_key_is_typed_without_changing_its_string_contract() {
17901        const KEY_ENV: &str = "CAR_TEST_A47_GENERIC_PROVIDER_KEY_DO_NOT_SET";
17902        const EXTRA_KEY_ENV_1: &str = "CAR_TEST_A47_GENERIC_EXTRA_KEY_1_DO_NOT_SET";
17903        const EXTRA_KEY_ENV_2: &str = "CAR_TEST_A47_GENERIC_EXTRA_KEY_2_DO_NOT_SET";
17904        let _provider_environment = crate::openrouter::test_environment_scope_async().await;
17905        let home = crate::openrouter::StateRootScope::new();
17906        let _restore = RestoredEnvironment::capture(&[
17907            "CAR_SECRETS_FILE_DIR",
17908            KEY_ENV,
17909            EXTRA_KEY_ENV_1,
17910            EXTRA_KEY_ENV_2,
17911        ]);
17912        unsafe {
17913            std::env::set_var("CAR_SECRETS_FILE_DIR", home.path().join("secrets"));
17914            std::env::remove_var(KEY_ENV);
17915            std::env::remove_var(EXTRA_KEY_ENV_1);
17916            std::env::remove_var(EXTRA_KEY_ENV_2);
17917        }
17918
17919        let endpoint = "http://127.0.0.1:9/a47-missing-key";
17920        let model_id = "test/a47-generic-missing-key";
17921        let models_dir = home.path().join("models");
17922        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
17923        let mut schema = remote_stream_fixture_schema(
17924            model_id,
17925            endpoint.into(),
17926            schema::ApiProtocol::OpenAiCompat,
17927            KEY_ENV,
17928        );
17929        let ModelSource::RemoteApi { api_key_envs, .. } = &mut schema.source else {
17930            panic!("generic remote fixture must use RemoteApi");
17931        };
17932        *api_key_envs = vec![EXTRA_KEY_ENV_1.into(), EXTRA_KEY_ENV_2.into()];
17933        engine.unified_registry.register_project_model(schema);
17934
17935        let error = engine
17936            .generate_tracked(GenerateRequest {
17937                prompt: "must stop before provider dispatch".into(),
17938                model: Some(model_id.into()),
17939                params: GenerateParams {
17940                    strict_model: true,
17941                    ..Default::default()
17942                },
17943                ..Default::default()
17944            })
17945            .await
17946            .expect_err("a generic remote route without a key must fail before dispatch");
17947
17948        let tracker = engine.outcome_tracker();
17949        assert!(
17950            tracker.read().await.profile(model_id).is_none(),
17951            "a missing generic provider key must not create model health"
17952        );
17953        let mut receipts = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
17954        receipts.extend(tracker.write().await.drain_ledger());
17955        let receipts = receipts
17956            .iter()
17957            .filter(|receipt| receipt.model_id == model_id)
17958            .collect::<Vec<_>>();
17959        assert_eq!(receipts.len(), 1);
17960        assert_eq!(receipts[0].success, None);
17961        assert_eq!(receipts[0].quality, None);
17962        assert!(
17963            engine
17964                .adaptive_router
17965                .circuit_breakers
17966                .lock()
17967                .unwrap()
17968                .state(model_id)
17969                .is_none(),
17970            "a missing generic provider key must not create breaker state"
17971        );
17972
17973        let message = format!(
17974            "no API keys available for endpoint {endpoint} (checked env vars: [\"{KEY_ENV}\", \"{EXTRA_KEY_ENV_1}\", \"{EXTRA_KEY_ENV_2}\"])"
17975        );
17976        assert_eq!(
17977            error.to_string(),
17978            format!("inference failed: {message}"),
17979            "guard: the pre-existing Display contract must stay byte-identical"
17980        );
17981        assert!(
17982            !is_auth_failure_message(&error.to_string()),
17983            "guard: a generic provider key is not repaired by Parslee sign-in"
17984        );
17985        assert_eq!(
17986            classify_fallback_reason(&error),
17987            classify_fallback_reason(&InferenceError::InferenceFailed(message.clone())),
17988            "retyping must preserve prior fallback classification"
17989        );
17990        match error {
17991            InferenceError::ProviderKeyMissing {
17992                provider,
17993                model,
17994                env_vars,
17995                message: actual_message,
17996            } => {
17997                assert_eq!(provider, "test");
17998                assert_eq!(model, model_id);
17999                assert_eq!(env_vars, vec![KEY_ENV, EXTRA_KEY_ENV_1, EXTRA_KEY_ENV_2]);
18000                assert_eq!(actual_message, message);
18001            }
18002            other => panic!("expected ProviderKeyMissing, got {other:?}"),
18003        }
18004    }
18005
18006    /// Parslee-ai/car#1544 — every typed credential-resolution failure stops
18007    /// before model execution, so each remains visible only as an unattributed
18008    /// receipt and leaves model health and breaker state untouched.
18009    #[test]
18010    fn credential_unavailable_outcomes_do_not_degrade_models_or_open_breakers() {
18011        for reason in [
18012            CredentialFailure::Expired { expires_at: 42 },
18013            CredentialFailure::SignedOut,
18014            CredentialFailure::StoreUnreadable,
18015            CredentialFailure::EnvVarMissing {
18016                env_var: "TEST_PROVIDER_KEY".into(),
18017            },
18018            CredentialFailure::RaceRetryable,
18019        ] {
18020            let model_id = "parslee/test-credential-outcome";
18021            let error = InferenceError::CredentialUnavailable {
18022                provider: "parslee".into(),
18023                model: model_id.into(),
18024                reason: reason.clone(),
18025                detail: "credential resolution stopped before dispatch".into(),
18026            };
18027            let mut tracker = OutcomeTracker::new();
18028            let trace = tracker.record_start(model_id, InferenceTask::Generate, "test");
18029            record_dispatch_failure(&mut tracker, &trace, &error);
18030
18031            assert!(
18032                tracker.profile(model_id).is_none(),
18033                "{reason:?} changed the model profile: {:?}",
18034                tracker.profile(model_id)
18035            );
18036            let receipts = tracker.drain_ledger();
18037            assert_eq!(receipts.len(), 1, "{reason:?}");
18038            assert_eq!(receipts[0].success, None, "{reason:?}");
18039            assert_eq!(receipts[0].quality, None, "{reason:?}");
18040
18041            let mut breakers = crate::routing_ext::CircuitBreakerRegistry::new(1, 60);
18042            if error_counts_against_circuit_breaker(&error) {
18043                breakers.record_failure(model_id);
18044            }
18045            assert!(
18046                breakers.state(model_id).is_none(),
18047                "{reason:?} created per-model breaker state"
18048            );
18049        }
18050    }
18051
18052    #[tokio::test(flavor = "current_thread")]
18053    async fn signed_out_managed_attempts_leave_health_and_breaker_untouched() {
18054        if !crate::run_in_isolated_test_process(
18055            "tests::signed_out_managed_attempts_leave_health_and_breaker_untouched",
18056            "CAR_SIGNED_OUT_MODEL_HEALTH_CHILD",
18057        ) {
18058            return;
18059        }
18060
18061        let _provider_environment = crate::openrouter::test_environment_scope_async().await;
18062        let home = crate::openrouter::StateRootScope::new();
18063        let _restore = RestoredEnvironment::capture(&[
18064            "CAR_SECRETS_FILE_DIR",
18065            car_auth::PARSLEE_ACCESS_TOKEN_KEY,
18066            car_auth::PARSLEE_API_BASE_KEY,
18067        ]);
18068        unsafe {
18069            std::env::set_var("CAR_SECRETS_FILE_DIR", home.path().join("secrets"));
18070            std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
18071            std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
18072        }
18073        crate::openrouter::clear_gateway_unconfigured();
18074        crate::parslee_credential::clear_credential_rejected();
18075
18076        let store = car_secrets::SecretStore::new();
18077        store
18078            .publish(
18079                &car_secrets::SecretRef::with_default_service(
18080                    car_secrets::PARSLEE_AUTH_STATE_V2_KEY,
18081                ),
18082                r#"{"schema":2,"revision":1,"generation":1,"accounts":[],"tombstone":true}"#,
18083            )
18084            .unwrap();
18085
18086        let models_dir = home.path().join("models");
18087        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
18088        let schema = crate::openrouter::curated_schemas()
18089            .into_iter()
18090            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
18091            .expect("managed alias fixture");
18092        let model_id = schema.id.clone();
18093        engine.unified_registry.register_project_model(schema);
18094
18095        // Three attempts are enough to open the default breaker on the old
18096        // path. No transport is reachable: each request stops at authority
18097        // resolution with the signed-out tombstone above.
18098        for _ in 0..3 {
18099            let error = engine
18100                .generate_tracked(GenerateRequest {
18101                    prompt: "must stop before provider dispatch".into(),
18102                    model: Some(model_id.clone()),
18103                    params: GenerateParams {
18104                        strict_model: true,
18105                        ..Default::default()
18106                    },
18107                    ..Default::default()
18108                })
18109                .await
18110                .expect_err("signed-out managed inference must be rejected");
18111            assert!(
18112                matches!(
18113                    &error,
18114                    InferenceError::CredentialUnavailable {
18115                        reason: CredentialFailure::SignedOut,
18116                        ..
18117                    }
18118                ),
18119                "unexpected signed-out error: {error:?}"
18120            );
18121        }
18122
18123        let tracker = engine.outcome_tracker();
18124        assert!(
18125            tracker.read().await.profile(&model_id).is_none(),
18126            "signed-out attempts must not create a model-health profile"
18127        );
18128        // Auto-save is interval-throttled: the first receipt is persisted and
18129        // later ones can still be buffered. Count both homes rather than
18130        // mistaking a persistence detail for a missing outcome.
18131        let mut receipts = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
18132        receipts.extend(tracker.write().await.drain_ledger());
18133        let signed_out_receipts = receipts
18134            .iter()
18135            .filter(|entry| entry.model_id == model_id)
18136            .collect::<Vec<_>>();
18137        assert_eq!(signed_out_receipts.len(), 3);
18138        assert!(signed_out_receipts
18139            .iter()
18140            .all(|entry| entry.success.is_none() && entry.quality.is_none()));
18141        assert!(
18142            engine
18143                .adaptive_router
18144                .circuit_breakers
18145                .lock()
18146                .unwrap()
18147                .state(&model_id)
18148                .is_none(),
18149            "signed-out attempts must not create per-model breaker state"
18150        );
18151    }
18152
18153    #[test]
18154    fn dispatch_outcome_guards_keep_real_failures_and_existing_rejections_distinct() {
18155        let model_id = "test/model";
18156
18157        let real_failure = InferenceError::InferenceFailed("provider returned HTTP 500".into());
18158        let mut tracker = OutcomeTracker::new();
18159        let trace = tracker.record_start(model_id, InferenceTask::Generate, "test");
18160        record_dispatch_failure(&mut tracker, &trace, &real_failure);
18161        let profile = tracker.profile(model_id).expect("real failure profile");
18162        assert_eq!(profile.success_count, 0);
18163        assert_eq!(profile.fail_count, 1);
18164        let receipt = tracker.drain_ledger().pop().unwrap();
18165        assert_eq!(receipt.success, Some(false));
18166        assert!(error_counts_against_circuit_breaker(&real_failure));
18167        let mut breakers = crate::routing_ext::CircuitBreakerRegistry::new(1, 60);
18168        breakers.record_failure(model_id);
18169        assert_eq!(
18170            breakers.state(model_id),
18171            Some(crate::routing_ext::CircuitState::Open)
18172        );
18173
18174        for rejection in [
18175            InferenceError::GatewayUnconfigured {
18176                provider: "parslee".into(),
18177                namespace: "parslee/openrouter/".into(),
18178                status: 503,
18179                message: "not configured".into(),
18180            },
18181            InferenceError::ContentRefused {
18182                provider: "parslee".into(),
18183                kind: Some("invalid_request_error".into()),
18184                code: Some("content_policy_violation".into()),
18185                message: "content refused".into(),
18186            },
18187        ] {
18188            let mut tracker = OutcomeTracker::new();
18189            let trace = tracker.record_start(model_id, InferenceTask::Generate, "test");
18190            record_dispatch_failure(&mut tracker, &trace, &rejection);
18191            assert!(
18192                tracker.profile(model_id).is_none(),
18193                "existing rejection changed model profile: {rejection}"
18194            );
18195            let receipt = tracker.drain_ledger().pop().unwrap();
18196            assert_eq!(receipt.success, None, "{rejection}");
18197            assert_eq!(receipt.quality, None, "{rejection}");
18198            assert!(!error_counts_against_circuit_breaker(&rejection));
18199        }
18200    }
18201
18202    /// Parslee-ai/car#650 — an out-of-credits account must not be recorded as
18203    /// the *model* being unreliable.
18204    ///
18205    /// A 402 (and 401/403) is account-wide: every model on that account fails
18206    /// it identically. Booking it through `record_failure` degraded the model's
18207    /// 30-day health EMA and tripped its per-model circuit breaker, and because
18208    /// the fallback loop walked every candidate on the account it did that to
18209    /// all of them at once. The user tops up their credits and the router keeps
18210    /// deprioritizing the models — a penalty that outlives its cause.
18211    ///
18212    /// The receipt is still written (operators need to see what happened); it
18213    /// just carries no success/quality verdict against the model.
18214    #[tokio::test(flavor = "current_thread")]
18215    async fn openrouter_out_of_credits_does_not_degrade_the_model() {
18216        use wiremock::matchers::{method, path};
18217        use wiremock::{Mock, MockServer, ResponseTemplate};
18218
18219        let _credential_scope = crate::openrouter::test_credential_scope();
18220        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
18221        let server = MockServer::start().await;
18222        Mock::given(method("POST"))
18223            .and(path("/v1/chat/completions"))
18224            .respond_with(
18225                ResponseTemplate::new(402)
18226                    .set_body_string(r#"{"error":{"code":402,"message":"Insufficient credits"}}"#),
18227            )
18228            .mount(&server)
18229            .await;
18230
18231        let tmp = TempDir::new().unwrap();
18232        let models_dir = tmp.path().join("models");
18233        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
18234        let mut schema = crate::openrouter::curated_schemas()
18235            .into_iter()
18236            .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
18237            .unwrap();
18238        if let ModelSource::RemoteApi { endpoint, .. } = &mut schema.source {
18239            *endpoint = server.uri();
18240        }
18241        let model_id = schema.id.clone();
18242        engine.unified_registry.register_project_model(schema);
18243
18244        let err = engine
18245            .generate_tracked(GenerateRequest {
18246                prompt: "bill me".into(),
18247                model: Some(model_id.clone()),
18248                params: GenerateParams {
18249                    strict_model: true,
18250                    ..Default::default()
18251                },
18252                ..Default::default()
18253            })
18254            .await
18255            .expect_err("402 must not succeed");
18256
18257        match &err {
18258            InferenceError::ProviderAccount {
18259                provider, status, ..
18260            } => {
18261                assert_eq!(status, &402);
18262                assert_eq!(provider, "openrouter");
18263            }
18264            other => panic!("expected ProviderAccount, got {other:?}"),
18265        }
18266        assert!(
18267            !error_counts_against_circuit_breaker(&err),
18268            "an account rejection must not feed the per-model breaker"
18269        );
18270
18271        // The model's routing profile carries no failure from someone's billing.
18272        let tracker_handle = engine.outcome_tracker();
18273        let tracker = tracker_handle.read().await;
18274        let profile = tracker.profile(&model_id).cloned();
18275        assert!(
18276            profile.as_ref().is_none_or(|p| p.fail_count == 0),
18277            "account rejection degraded the model profile: {profile:?}"
18278        );
18279        drop(tracker);
18280
18281        // ...but the attempt is still on the receipt ledger, unattributed. The
18282        // post-call auto-save has already drained the in-memory buffer to disk,
18283        // so read it back from where operators actually look.
18284        let ledger = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
18285        let entry = ledger
18286            .iter()
18287            .find(|e| e.model_id == model_id)
18288            .expect("the attempt must still produce a receipt");
18289        assert!(
18290            entry.success.is_none() && entry.quality.is_none(),
18291            "receipt must record the attempt without a verdict: {entry:?}"
18292        );
18293
18294        // And the breaker was never touched for this model.
18295        assert!(
18296            engine
18297                .adaptive_router
18298                .circuit_breakers
18299                .lock()
18300                .unwrap()
18301                .state(&model_id)
18302                .is_none(),
18303            "account rejection must not create breaker state for the model"
18304        );
18305    }
18306
18307    #[tokio::test(flavor = "current_thread")]
18308    async fn openrouter_stream_error_records_failure_and_never_completes_successfully() {
18309        use wiremock::matchers::{method, path};
18310        use wiremock::{Mock, MockServer, ResponseTemplate};
18311
18312        let _credential_scope = crate::openrouter::test_credential_scope();
18313        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
18314        let server = MockServer::start().await;
18315        Mock::given(method("POST"))
18316            .and(path("/v1/chat/completions"))
18317            .respond_with(ResponseTemplate::new(200).set_body_raw(
18318                "data: {\"error\":{\"code\":402,\"message\":\"private balance details\"}}\n\n",
18319                "text/event-stream",
18320            ))
18321            .mount(&server)
18322            .await;
18323
18324        let tmp = TempDir::new().unwrap();
18325        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18326        let mut schema = crate::openrouter::curated_schemas()
18327            .into_iter()
18328            .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
18329            .unwrap();
18330        if let ModelSource::RemoteApi { endpoint, .. } = &mut schema.source {
18331            *endpoint = server.uri();
18332        }
18333        let model_id = schema.id.clone();
18334        engine.unified_registry.register_project_model(schema);
18335
18336        let mut handle = engine
18337            .generate_tracked_stream(GenerateRequest {
18338                prompt: "fail after headers".into(),
18339                model: Some(model_id.clone()),
18340                ..Default::default()
18341            })
18342            .await
18343            .unwrap();
18344        let mut events = Vec::new();
18345        while let Some(event) = handle.events.recv().await {
18346            events.push(event);
18347        }
18348        assert!(matches!(
18349            events.as_slice(),
18350            [StreamEvent::Error(message)] if message == "OpenRouter account is out of credits"
18351        ));
18352        assert!(!events
18353            .iter()
18354            .any(|event| matches!(event, StreamEvent::Done { .. })));
18355
18356        for _ in 0..50 {
18357            if engine
18358                .outcome_tracker()
18359                .read()
18360                .await
18361                .profile(&model_id)
18362                .is_some_and(|profile| profile.fail_count == 1)
18363            {
18364                break;
18365            }
18366            tokio::task::yield_now().await;
18367        }
18368        let tracker = engine.outcome_tracker();
18369        let profile = tracker.read().await.profile(&model_id).cloned().unwrap();
18370        assert_eq!(profile.fail_count, 1);
18371        assert_eq!(profile.success_count, 0);
18372    }
18373
18374    #[tokio::test(flavor = "current_thread")]
18375    async fn tracked_stream_without_done_records_failure_not_success() {
18376        let _offload_guard = crate::offload::test_offload_lock().lock().await;
18377        let tmp = TempDir::new().unwrap();
18378        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18379        pin_test_local_admission_memory(&mut engine);
18380        let model_id = install_small_local_fixture(&engine);
18381        crate::offload::set_local_offload(Some(Arc::new(FixtureLocalOffload { emit_done: false })));
18382
18383        let mut stream = engine
18384            .generate_tracked_stream(GenerateRequest {
18385                prompt: "must not count as success".into(),
18386                model: Some(model_id.clone()),
18387                params: GenerateParams {
18388                    strict_model: true,
18389                    ..Default::default()
18390                },
18391                ..Default::default()
18392            })
18393            .await
18394            .unwrap();
18395        while stream.events.recv().await.is_some() {}
18396        crate::offload::set_local_offload(None);
18397
18398        for _ in 0..50 {
18399            if engine
18400                .outcome_tracker()
18401                .read()
18402                .await
18403                .profile(&model_id)
18404                .is_some_and(|profile| profile.fail_count == 1)
18405            {
18406                break;
18407            }
18408            tokio::task::yield_now().await;
18409        }
18410        let profile = engine
18411            .outcome_tracker()
18412            .read()
18413            .await
18414            .profile(&model_id)
18415            .cloned()
18416            .unwrap();
18417        assert_eq!(profile.fail_count, 1);
18418        assert_eq!(profile.success_count, 0);
18419    }
18420
18421    #[tokio::test(flavor = "current_thread")]
18422    async fn google_vertex_terminal_matrix_records_only_deliberate_finishes_as_success() {
18423        use wiremock::matchers::{method, path};
18424        use wiremock::{Mock, MockServer, ResponseTemplate};
18425
18426        let _provider_env = crate::openrouter::test_environment_scope_async().await;
18427        let _env = ENV_MUTEX.lock().await;
18428        unsafe { std::env::set_var("CAR_GOOGLE_OUTCOME_MATRIX_KEY", "matrix-key") };
18429
18430        for (protocol, reason, should_succeed) in [
18431            (schema::ApiProtocol::Google, "STOP", true),
18432            (schema::ApiProtocol::Google, "SAFETY", false),
18433            (schema::ApiProtocol::VertexAi, "MAX_TOKENS", true),
18434            (
18435                schema::ApiProtocol::VertexAi,
18436                "MALFORMED_FUNCTION_CALL",
18437                false,
18438            ),
18439        ] {
18440            let server = MockServer::start().await;
18441            let expected_path = match protocol {
18442                schema::ApiProtocol::Google => "/v1beta/models/gemini-test:streamGenerateContent",
18443                schema::ApiProtocol::VertexAi => {
18444                    "/publishers/google/models/gemini-test:streamGenerateContent"
18445                }
18446                _ => unreachable!(),
18447            };
18448            Mock::given(method("POST"))
18449                .and(path(expected_path))
18450                .respond_with(ResponseTemplate::new(200).set_body_raw(
18451                    format!(
18452                        "data: {{\"candidates\":[{{\"content\":{{\"parts\":[{{\"text\":\"matrix\"}}]}},\"finishReason\":\"{reason}\"}}]}}\n\n"
18453                    ),
18454                    "text/event-stream",
18455                ))
18456                .mount(&server)
18457                .await;
18458
18459            let id = format!("test/{protocol:?}-{reason}");
18460            let tmp = TempDir::new().unwrap();
18461            let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18462            engine.register_model(remote_stream_fixture_schema(
18463                &id,
18464                server.uri(),
18465                protocol,
18466                "CAR_GOOGLE_OUTCOME_MATRIX_KEY",
18467            ));
18468            let mut stream = engine
18469                .generate_tracked_stream(GenerateRequest {
18470                    prompt: "matrix".into(),
18471                    model: Some(id.clone()),
18472                    params: GenerateParams {
18473                        strict_model: true,
18474                        ..Default::default()
18475                    },
18476                    ..Default::default()
18477                })
18478                .await
18479                .unwrap();
18480            let mut events = Vec::new();
18481            while let Some(event) = stream.events.recv().await {
18482                events.push(event);
18483            }
18484            for _ in 0..50 {
18485                if engine
18486                    .outcome_tracker()
18487                    .read()
18488                    .await
18489                    .profile(&id)
18490                    .is_some_and(|profile| profile.total_calls == 1)
18491                {
18492                    break;
18493                }
18494                tokio::task::yield_now().await;
18495            }
18496            let profile = engine
18497                .outcome_tracker()
18498                .read()
18499                .await
18500                .profile(&id)
18501                .cloned()
18502                .unwrap();
18503            assert_eq!(
18504                (profile.success_count, profile.fail_count),
18505                if should_succeed { (1, 0) } else { (0, 1) },
18506                "{protocol:?}/{reason}: {events:?}"
18507            );
18508        }
18509
18510        unsafe { std::env::remove_var("CAR_GOOGLE_OUTCOME_MATRIX_KEY") };
18511    }
18512
18513    #[tokio::test(flavor = "current_thread")]
18514    async fn remote_primary_stream_setup_failure_falls_back_to_installed_local_dispatch() {
18515        use wiremock::matchers::{method, path};
18516        use wiremock::{Mock, MockServer, ResponseTemplate};
18517
18518        let _offload_guard = crate::offload::test_offload_lock().lock().await;
18519        let _env = ENV_MUTEX.lock().await;
18520        let server = MockServer::start().await;
18521        Mock::given(method("POST"))
18522            .and(path("/v1/chat/completions"))
18523            .respond_with(ResponseTemplate::new(503))
18524            .mount(&server)
18525            .await;
18526        unsafe { std::env::set_var("CAR_STREAM_FALLBACK_TEST_KEY", "fixture") };
18527
18528        let tmp = TempDir::new().unwrap();
18529        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18530        pin_test_local_admission_memory(&mut engine);
18531        let _local_id = install_small_local_fixture(&engine);
18532        let remote_id = "test/remote-primary";
18533        engine.register_model(ModelSchema {
18534            id: remote_id.into(),
18535            name: "remote-primary".into(),
18536            provider: "test".into(),
18537            family: "test".into(),
18538            version: "1".into(),
18539            capabilities: vec![ModelCapability::Generate],
18540            context_length: 8_192,
18541            max_output_tokens: Some(1_024),
18542            param_count: String::new(),
18543            quantization: None,
18544            performance: Default::default(),
18545            cost: Default::default(),
18546            source: ModelSource::RemoteApi {
18547                endpoint: server.uri(),
18548                api_key_env: "CAR_STREAM_FALLBACK_TEST_KEY".into(),
18549                api_key_envs: vec![],
18550                api_version: None,
18551                protocol: schema::ApiProtocol::OpenAiCompat,
18552            },
18553            tags: vec![],
18554            supported_params: vec![],
18555            public_benchmarks: vec![],
18556            trust_tier: TrustTier::Community,
18557            deprecated: false,
18558            available: true,
18559            weights_ready: true,
18560        });
18561        crate::offload::set_local_offload(Some(Arc::new(FixtureLocalOffload { emit_done: true })));
18562
18563        let mut stream = engine
18564            .generate_tracked_stream(GenerateRequest {
18565                prompt: "fall back".into(),
18566                model: Some(remote_id.into()),
18567                ..Default::default()
18568            })
18569            .await
18570            .expect("compatible installed local model should be dispatched");
18571        assert_ne!(stream.model_used, remote_id);
18572        assert!(
18573            engine
18574                .unified_registry
18575                .get(&stream.model_used)
18576                .is_some_and(ModelSchema::is_local),
18577            "fallback must stay on a compatible local model: {}",
18578            stream.model_used
18579        );
18580        let mut saw_done = false;
18581        while let Some(event) = stream.events.recv().await {
18582            saw_done |= matches!(event, StreamEvent::Done { .. });
18583        }
18584        assert!(saw_done);
18585
18586        crate::offload::set_local_offload(None);
18587        unsafe { std::env::remove_var("CAR_STREAM_FALLBACK_TEST_KEY") };
18588    }
18589
18590    #[tokio::test(flavor = "current_thread")]
18591    async fn exact_model_id_rejects_display_name_without_dispatch() {
18592        let tmp = TempDir::new().unwrap();
18593        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18594        let mut first = remote_stream_fixture_schema(
18595            "test/ambiguous-a:1",
18596            "http://127.0.0.1:9".into(),
18597            schema::ApiProtocol::OpenAiCompat,
18598            "CAR_AMBIGUOUS_EXACT_KEY",
18599        );
18600        first.name = "Shared Alias".into();
18601        let mut second = first.clone();
18602        second.id = "test/ambiguous-b:1".into();
18603        engine.register_model(first);
18604        engine.register_model(second);
18605        let mut request = GenerateRequest {
18606            prompt: "must not dispatch".into(),
18607            ..Default::default()
18608        };
18609        pin_exact_model_id(&mut request, "Shared Alias".into()).unwrap();
18610
18611        let error = engine
18612            .generate_tracked(request)
18613            .await
18614            .expect_err("an exact-id pin must never resolve a display name");
18615        assert!(matches!(error, InferenceError::ModelNotFound(_)));
18616    }
18617
18618    #[tokio::test(flavor = "current_thread")]
18619    async fn exact_openai_pin_reports_catalog_id_and_loose_request_keeps_provider_name() {
18620        let _env = ENV_MUTEX.lock().await;
18621        assert_remote_model_identity_contract(
18622            schema::ApiProtocol::OpenAiCompat,
18623            "openai/newsroom-gpt-5.5-2026-04-23:test",
18624            "newsroom-gpt-5.5-2026-04-23-test",
18625            "CAR_OPENAI_IDENTITY_TEST_KEY",
18626        )
18627        .await;
18628    }
18629
18630    #[tokio::test(flavor = "current_thread")]
18631    async fn exact_anthropic_pin_reports_catalog_id_and_loose_request_keeps_provider_name() {
18632        let _env = ENV_MUTEX.lock().await;
18633        assert_remote_model_identity_contract(
18634            schema::ApiProtocol::Anthropic,
18635            "anthropic/newsroom-claude-opus-4-8:test",
18636            "newsroom-claude-opus-4-8-test",
18637            "CAR_ANTHROPIC_IDENTITY_TEST_KEY",
18638        )
18639        .await;
18640    }
18641
18642    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
18643    #[tokio::test(flavor = "current_thread")]
18644    async fn exact_model_id_nonstream_bypasses_mlx_equivalent_substitution() {
18645        let _offload_guard = crate::offload::test_offload_lock().lock().await;
18646        let tmp = TempDir::new().unwrap();
18647        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18648        let exact_id = install_exact_pin_equivalent_fixture(&engine);
18649        let offload = Arc::new(ExactPinCaptureOffload::new());
18650        crate::offload::set_local_offload(Some(offload.clone()));
18651
18652        let mut request = GenerateRequest {
18653            prompt: "use only the immutable row I selected".into(),
18654            ..Default::default()
18655        };
18656        pin_exact_model_id(&mut request, exact_id.clone()).unwrap();
18657        let error = engine.generate_tracked(request).await.unwrap_err();
18658
18659        assert!(offload.dispatched_models().is_empty());
18660        assert!(error.to_string().contains(&exact_id), "{error}");
18661        assert!(
18662            error
18663                .to_string()
18664                .contains("MLX-equivalent substitution is disabled for exact pins"),
18665            "{error}"
18666        );
18667        crate::offload::set_local_offload(None);
18668    }
18669
18670    #[tokio::test(flavor = "current_thread")]
18671    async fn catalog_identity_mismatch_rejects_before_provider_dispatch() {
18672        use wiremock::matchers::{method, path};
18673        use wiremock::{Mock, MockServer, ResponseTemplate};
18674
18675        let _env = ENV_MUTEX.lock().await;
18676        let server = MockServer::start().await;
18677        Mock::given(method("POST"))
18678            .and(path("/v1/chat/completions"))
18679            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
18680                "choices": [{"message": {"content": "must not run"}, "finish_reason": "stop"}],
18681            })))
18682            .mount(&server)
18683            .await;
18684        unsafe { std::env::set_var("CAR_PRECONDITION_TEST_KEY", "fixture") };
18685
18686        let tmp = TempDir::new().unwrap();
18687        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18688        let model_id = "test/catalog-precondition:1";
18689        engine.register_model(remote_stream_fixture_schema(
18690            model_id,
18691            server.uri(),
18692            schema::ApiProtocol::OpenAiCompat,
18693            "CAR_PRECONDITION_TEST_KEY",
18694        ));
18695        let snapshot = engine.catalog_snapshot().unwrap();
18696        let row_digest = snapshot
18697            .model_by_exact_id(model_id)
18698            .unwrap()
18699            .row_digest
18700            .clone();
18701
18702        for (expected_row_digest, expected_catalog_revision) in [
18703            (
18704                Some("0".repeat(64)),
18705                Some(snapshot.catalog_revision.clone()),
18706            ),
18707            (Some(row_digest.clone()), Some("f".repeat(64))),
18708        ] {
18709            let mut request = GenerateRequest {
18710                prompt: "must not dispatch".into(),
18711                expected_row_digest,
18712                expected_catalog_revision,
18713                ..Default::default()
18714            };
18715            pin_exact_model_id(&mut request, model_id.into()).unwrap();
18716            let error = engine.generate_tracked(request).await.unwrap_err();
18717            assert!(matches!(
18718                error,
18719                InferenceError::CatalogPreconditionMismatch { .. }
18720            ));
18721        }
18722        assert!(
18723            server.received_requests().await.unwrap().is_empty(),
18724            "catalog identity mismatches must fail before provider dispatch"
18725        );
18726
18727        unsafe { std::env::remove_var("CAR_PRECONDITION_TEST_KEY") };
18728    }
18729
18730    #[tokio::test(flavor = "current_thread")]
18731    async fn exact_nonstream_identity_survives_thinking_retry() {
18732        use std::sync::atomic::{AtomicUsize, Ordering};
18733        use wiremock::matchers::{method, path};
18734        use wiremock::{Mock, MockServer, ResponseTemplate};
18735
18736        let _env = ENV_MUTEX.lock().await;
18737        let server = MockServer::start().await;
18738        let calls = Arc::new(AtomicUsize::new(0));
18739        let response_calls = calls.clone();
18740        Mock::given(method("POST"))
18741            .and(path("/v1/chat/completions"))
18742            .respond_with(move |_request: &wiremock::Request| {
18743                let attempt = response_calls.fetch_add(1, Ordering::SeqCst);
18744                let content = if attempt == 0 { "" } else { "recovered" };
18745                ResponseTemplate::new(200).set_body_json(serde_json::json!({
18746                    "choices": [{
18747                        "message": {"content": content},
18748                        "finish_reason": if attempt == 0 { "length" } else { "stop" },
18749                    }],
18750                    "usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
18751                }))
18752            })
18753            .mount(&server)
18754            .await;
18755        unsafe { std::env::set_var("CAR_IDENTITY_RETRY_KEY", "fixture") };
18756
18757        let tmp = TempDir::new().unwrap();
18758        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18759        let model_id = "test/identity-retry:1";
18760        engine.register_model(remote_stream_fixture_schema(
18761            model_id,
18762            server.uri(),
18763            schema::ApiProtocol::OpenAiCompat,
18764            "CAR_IDENTITY_RETRY_KEY",
18765        ));
18766        let snapshot = engine.catalog_snapshot().unwrap();
18767        let expected_row = snapshot.model_by_exact_id(model_id).unwrap();
18768        let mut request = GenerateRequest {
18769            prompt: "retry once".into(),
18770            expected_row_digest: Some(expected_row.row_digest.clone()),
18771            expected_catalog_revision: Some(snapshot.catalog_revision.clone()),
18772            ..Default::default()
18773        };
18774        pin_exact_model_id(&mut request, model_id.into()).unwrap();
18775
18776        let result = engine.generate_tracked(request).await.unwrap();
18777        assert_eq!(calls.load(Ordering::SeqCst), 2, "must exercise retry path");
18778        assert_eq!(result.stop_reason.as_deref(), Some("thinking_recovered"));
18779        assert_eq!(
18780            result.model_identity.requested_model_id.as_deref(),
18781            Some(model_id)
18782        );
18783        assert_eq!(result.model_identity.resolved_model_id, model_id);
18784        assert_eq!(result.model_identity.row_digest, expected_row.row_digest);
18785        assert_eq!(
18786            result.model_identity.catalog_revision,
18787            snapshot.catalog_revision
18788        );
18789
18790        unsafe { std::env::remove_var("CAR_IDENTITY_RETRY_KEY") };
18791    }
18792
18793    #[tokio::test(flavor = "current_thread")]
18794    async fn managed_openrouter_reasoning_items_roundtrip_verbatim_across_two_turns() {
18795        if !crate::run_in_isolated_test_process(
18796            "tests::managed_openrouter_reasoning_items_roundtrip_verbatim_across_two_turns",
18797            "CAR_MANAGED_REASONING_ROUNDTRIP_CHILD",
18798        ) {
18799            return;
18800        }
18801        let _home = crate::openrouter::StateRootScope::new();
18802        use wiremock::matchers::{header, method, path};
18803        use wiremock::{Mock, MockServer, ResponseTemplate};
18804
18805        let _provider_env = crate::openrouter::test_environment_scope_async().await;
18806        let _env = ENV_MUTEX.lock().await;
18807        let bearer = "managed-reasoning-roundtrip-bearer";
18808
18809        let server = MockServer::start().await;
18810        unsafe {
18811            std::env::set_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV, bearer);
18812            std::env::set_var(car_auth::PARSLEE_API_BASE_KEY, server.uri());
18813        }
18814        Mock::given(method("GET"))
18815            .and(path("/api/v1/organizations/me"))
18816            .and(header("authorization", format!("Bearer {bearer}")))
18817            .respond_with(
18818                ResponseTemplate::new(200)
18819                    .set_body_json(serde_json::json!({"organizationId": "org-roundtrip"})),
18820            )
18821            .mount(&server)
18822            .await;
18823        Mock::given(method("GET"))
18824            .and(path("/connect/session"))
18825            .respond_with(
18826                ResponseTemplate::new(200)
18827                    .set_body_json(serde_json::json!({"account": {"email": "user@example.test"}})),
18828            )
18829            .mount(&server)
18830            .await;
18831        Mock::given(method("POST"))
18832            .and(path("/api/v1/orgs/org-roundtrip/inference/responses"))
18833            .respond_with(ResponseTemplate::new(200).set_body_raw(
18834                include_str!("../tests/fixtures/parslee-openrouter-reasoning-roundtrip.sse"),
18835                "text/event-stream",
18836            ))
18837            .expect(2)
18838            .mount(&server)
18839            .await;
18840
18841        let tmp = TempDir::new().unwrap();
18842        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18843        let schema = crate::openrouter::curated_schemas()
18844            .into_iter()
18845            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
18846            .unwrap();
18847        engine.unified_registry.register_project_model(schema);
18848
18849        let first = engine
18850            .generate_tracked(GenerateRequest {
18851                prompt: "first".into(),
18852                model: Some("parslee/openrouter/frontier-general".into()),
18853                ..Default::default()
18854            })
18855            .await
18856            .expect("first managed turn");
18857        let expected_reasoning = serde_json::json!({
18858            "type": "reasoning",
18859            "id": "rs_car_roundtrip",
18860            "status": "completed",
18861            "summary": [{"type": "summary_text", "text": "safe summary"}],
18862            "encrypted_content": "opaque-encrypted-reasoning",
18863        });
18864        assert_eq!(
18865            first.provider_output_items,
18866            vec![expected_reasoning.clone()]
18867        );
18868
18869        let mut history = vec![Message::User {
18870            content: "first".into(),
18871        }];
18872        first.append_assistant_history(&mut history, first.tool_calls.clone());
18873        history.push(Message::User {
18874            content: "continue".into(),
18875        });
18876
18877        let second = engine
18878            .generate_tracked(GenerateRequest {
18879                prompt: String::new(),
18880                model: Some("parslee/openrouter/frontier-general".into()),
18881                messages: Some(history),
18882                ..Default::default()
18883            })
18884            .await
18885            .expect("second managed turn");
18886        assert_eq!(second.text, "first answer");
18887
18888        let requests = server.received_requests().await.unwrap();
18889        let posts: Vec<serde_json::Value> = requests
18890            .iter()
18891            .filter(|request| {
18892                request.method.as_str() == "POST"
18893                    && request.url.path() == "/api/v1/orgs/org-roundtrip/inference/responses"
18894            })
18895            .map(|request| serde_json::from_slice(&request.body).unwrap())
18896            .collect();
18897        assert_eq!(posts.len(), 2);
18898        for body in &posts {
18899            assert_eq!(body["store"], false);
18900            assert_eq!(
18901                body["include"],
18902                serde_json::json!(["reasoning.encrypted_content"])
18903            );
18904        }
18905        let second_input = posts[1]["input"].as_array().unwrap();
18906        let reasoning_index = second_input
18907            .iter()
18908            .position(|item| item == &expected_reasoning)
18909            .expect("second request must replay the exact reasoning item");
18910        let assistant_index = second_input
18911            .iter()
18912            .position(|item| item["role"] == "assistant")
18913            .expect("second request assistant turn");
18914        let user_index = second_input
18915            .iter()
18916            .position(|item| item["content"] == "continue")
18917            .expect("second request user turn");
18918        assert!(
18919            reasoning_index < assistant_index && assistant_index < user_index,
18920            "provider output order must be reasoning, assistant text, then the next user turn"
18921        );
18922
18923        unsafe {
18924            std::env::remove_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV);
18925            std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
18926        }
18927    }
18928
18929    #[tokio::test(flavor = "current_thread")]
18930    async fn managed_partial_eof_fails_buffered_and_streamed_turns_and_records_only_failures() {
18931        if !crate::run_in_isolated_test_process(
18932            "tests::managed_partial_eof_fails_buffered_and_streamed_turns_and_records_only_failures",
18933            "CAR_MANAGED_PARTIAL_EOF_CHILD",
18934        ) {
18935            return;
18936        }
18937        let _home = crate::openrouter::StateRootScope::new();
18938        use wiremock::matchers::{header, method, path};
18939        use wiremock::{Mock, MockServer, ResponseTemplate};
18940
18941        let _provider_env = crate::openrouter::test_environment_scope_async().await;
18942        let _env = ENV_MUTEX.lock().await;
18943        let bearer = "managed-partial-outcome-bearer";
18944
18945        let server = MockServer::start().await;
18946        unsafe {
18947            std::env::set_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV, bearer);
18948            std::env::set_var(car_auth::PARSLEE_API_BASE_KEY, server.uri());
18949        }
18950        Mock::given(method("GET"))
18951            .and(path("/api/v1/organizations/me"))
18952            .and(header("authorization", format!("Bearer {bearer}")))
18953            .respond_with(
18954                ResponseTemplate::new(200)
18955                    .set_body_json(serde_json::json!({"organizationId": "org-partial-outcome"})),
18956            )
18957            .mount(&server)
18958            .await;
18959        Mock::given(method("GET"))
18960            .and(path("/connect/session"))
18961            .respond_with(
18962                ResponseTemplate::new(200)
18963                    .set_body_json(serde_json::json!({"account": {"email": "user@example.test"}})),
18964            )
18965            .mount(&server)
18966            .await;
18967        Mock::given(method("POST"))
18968            .and(path("/api/v1/orgs/org-partial-outcome/inference/responses"))
18969            .respond_with(ResponseTemplate::new(200).set_body_raw(
18970                "event: response.output_text.delta\ndata: {\"delta\":\"partial must fail\"}\n\n",
18971                "text/event-stream",
18972            ))
18973            .expect(2)
18974            .mount(&server)
18975            .await;
18976
18977        let tmp = TempDir::new().unwrap();
18978        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18979        let model_id = "parslee/openrouter/frontier-general";
18980        let schema = crate::openrouter::curated_schemas()
18981            .into_iter()
18982            .find(|schema| schema.id == model_id)
18983            .unwrap();
18984        engine.unified_registry.register_project_model(schema);
18985
18986        let buffered_error = engine
18987            .generate_tracked(GenerateRequest {
18988                prompt: "buffered".into(),
18989                model: Some(model_id.into()),
18990                params: GenerateParams {
18991                    strict_model: true,
18992                    ..Default::default()
18993                },
18994                ..Default::default()
18995            })
18996            .await
18997            .expect_err("buffered partial EOF must fail");
18998        assert!(
18999            buffered_error.to_string().contains("response.completed"),
19000            "unexpected buffered error: {buffered_error}"
19001        );
19002
19003        let mut stream = engine
19004            .generate_tracked_stream(GenerateRequest {
19005                prompt: "streamed".into(),
19006                model: Some(model_id.into()),
19007                params: GenerateParams {
19008                    strict_model: true,
19009                    ..Default::default()
19010                },
19011                ..Default::default()
19012            })
19013            .await
19014            .expect("HTTP streaming request starts");
19015        let mut events = Vec::new();
19016        while let Some(event) = stream.events.recv().await {
19017            events.push(event);
19018        }
19019        assert!(
19020            matches!(events.last(), Some(StreamEvent::Error(message)) if message.contains("response.completed"))
19021        );
19022        assert!(!events
19023            .iter()
19024            .any(|event| matches!(event, StreamEvent::Done { .. })));
19025
19026        for _ in 0..50 {
19027            if engine
19028                .outcome_tracker()
19029                .read()
19030                .await
19031                .profile(model_id)
19032                .is_some_and(|profile| profile.fail_count == 2)
19033            {
19034                break;
19035            }
19036            tokio::task::yield_now().await;
19037        }
19038        let profile = engine
19039            .outcome_tracker()
19040            .read()
19041            .await
19042            .profile(model_id)
19043            .cloned()
19044            .unwrap();
19045        assert_eq!(profile.fail_count, 2);
19046        assert_eq!(profile.success_count, 0);
19047
19048        unsafe {
19049            std::env::remove_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV);
19050            std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
19051        }
19052    }
19053
19054    #[tokio::test]
19055    async fn tokenize_rejects_known_remote_model_with_unsupported_mode() {
19056        // The unified registry's built-in catalog includes remote models like
19057        // OpenAI / Anthropic ones. Regardless of which exact id ships, we just
19058        // need any non-local schema to confirm the pre-flight catches it
19059        // before we try (and fail) to load a non-existent local backend.
19060        let tmp = TempDir::new().unwrap();
19061        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19062        let remote_id = engine
19063            .list_schemas()
19064            .into_iter()
19065            .find(|s| !s.is_local())
19066            .map(|s| s.id)
19067            .expect("built-in catalog should include at least one remote model schema");
19068
19069        let err = engine
19070            .tokenize(&remote_id, "hello")
19071            .await
19072            .expect_err("remote tokenize must error");
19073        match err {
19074            InferenceError::UnsupportedMode { mode, backend, .. } => {
19075                assert_eq!(mode, "tokenize/detokenize");
19076                assert_eq!(backend, "remote");
19077            }
19078            other => panic!("expected UnsupportedMode, got {other:?}"),
19079        }
19080
19081        let err = engine
19082            .detokenize(&remote_id, &[1, 2, 3])
19083            .await
19084            .expect_err("remote detokenize must error");
19085        assert!(
19086            matches!(err, InferenceError::UnsupportedMode { .. }),
19087            "expected UnsupportedMode, got {err:?}"
19088        );
19089    }
19090
19091    #[test]
19092    fn unsupported_mode_does_not_trip_circuit_breaker() {
19093        // A deterministic capability mismatch (JsonSchema response_format on
19094        // Anthropic, or a video/audio block on a text-only provider) must NOT
19095        // feed the circuit breaker — it would evict a healthy model for ALL
19096        // traffic. Genuine availability errors still count. This locks the guard
19097        // in the dispatch loop's Err arm.
19098        let unsupported = InferenceError::UnsupportedMode {
19099            mode: "structured-output-json-schema",
19100            backend: "anthropic",
19101            reason: "not wired under the pinned API version",
19102        };
19103        assert!(!error_counts_against_circuit_breaker(&unsupported));
19104        assert!(error_counts_against_circuit_breaker(
19105            &InferenceError::InferenceFailed("API returned 500".into())
19106        ));
19107        assert!(error_counts_against_circuit_breaker(
19108            &InferenceError::InferenceFailed("API returned 429".into())
19109        ));
19110    }
19111
19112    /// Parslee-ai/car#796 — a content refusal must not reach model health.
19113    ///
19114    /// The model handles the same payload correctly when the request gets
19115    /// through; the refusal came from a filter in front of it. Benching the
19116    /// model for that would make an adversarial-safety suite progressively
19117    /// evict the very models it is trying to measure — the suite's whole job is
19118    /// to send input that trips filters.
19119    #[test]
19120    fn a_content_refusal_does_not_trip_the_circuit_breaker() {
19121        let refused = InferenceError::ContentRefused {
19122            provider: "parslee".into(),
19123            kind: Some("invalid_request_error".into()),
19124            code: Some("content_policy_violation".into()),
19125            message: "content refused".into(),
19126        };
19127        assert!(!error_counts_against_circuit_breaker(&refused));
19128
19129        // The rendering must carry the classification, since that is what lets
19130        // a benchmark score a refusal apart from a crash.
19131        let rendered = refused.to_string();
19132        assert!(rendered.contains("content grounds"), "{rendered}");
19133        assert!(rendered.contains("content_policy_violation"), "{rendered}");
19134
19135        // A generic failure still counts — the exclusion must be narrow.
19136        assert!(error_counts_against_circuit_breaker(
19137            &InferenceError::InferenceFailed("managed inference failed".into())
19138        ));
19139    }
19140
19141    /// Parslee-ai/car#796 — a content refusal ENDS the chain rather than being
19142    /// answered by the next candidate.
19143    ///
19144    /// The chain cannot vary the request, so every remaining candidate replays
19145    /// the payload the filter just declined — and the tail of a remote-only
19146    /// chain is an appended on-device model with no filter in front of it. Left
19147    /// to fall through, a refused `parslee/reasoning` call returns a *local*
19148    /// model's answer under the requested model's name, which is what makes an
19149    /// adversarial-safety benchmark's counts move run to run.
19150    #[test]
19151    fn a_content_refusal_ends_the_fallback_chain() {
19152        let refused = InferenceError::ContentRefused {
19153            provider: "parslee".into(),
19154            kind: Some("invalid_request_error".into()),
19155            code: Some("content_policy_violation".into()),
19156            message: "content refused".into(),
19157        };
19158        assert!(error_ends_fallback_chain(&refused));
19159
19160        // Narrow, and in the safe direction: every condition that IS about a
19161        // lane keeps advancing the chain, or a single dead credential would
19162        // start failing calls that a fallback would have served.
19163        for still_advances in [
19164            InferenceError::InferenceFailed("managed inference failed".into()),
19165            InferenceError::InferenceFailed("API returned 503".into()),
19166            InferenceError::ModelNotFound("parslee/reasoning".into()),
19167            InferenceError::UnsupportedMode {
19168                mode: "json_schema",
19169                backend: "anthropic",
19170                reason: "structured output is not supported by this protocol",
19171            },
19172            InferenceError::GatewayUnconfigured {
19173                provider: "parslee".into(),
19174                namespace: "parslee/openrouter/".into(),
19175                status: 503,
19176                message: "not configured".into(),
19177            },
19178        ] {
19179            assert!(
19180                !error_ends_fallback_chain(&still_advances),
19181                "must keep advancing the chain: {still_advances}"
19182            );
19183        }
19184    }
19185
19186    /// Parslee-ai/car#796 — the exhausted-chain recovery hints must not launder
19187    /// a content refusal back into a generic failure.
19188    ///
19189    /// Both hints match SUBSTRINGS of the Display text, and a refusal embeds the
19190    /// gateway's own message verbatim. A gateway that says "403 forbidden" while
19191    /// refusing on content grounds would otherwise be rewritten to
19192    /// `InferenceFailed` and lose the classification one layer after it was
19193    /// finally earned.
19194    #[test]
19195    fn recovery_hints_do_not_rewrite_a_content_refusal() {
19196        let refused = InferenceError::ContentRefused {
19197            provider: "parslee".into(),
19198            kind: Some("invalid_request_error".into()),
19199            code: Some("content_policy_violation".into()),
19200            // Deliberately quotes a phrase the auth-expired hint matches on.
19201            message: "blocked: 403 forbidden by the content filter".into(),
19202        };
19203        let out = apply_exhaustion_recovery_hint(refused);
19204        assert!(
19205            matches!(out, InferenceError::ContentRefused { .. }),
19206            "{out:?}"
19207        );
19208
19209        // The hints still fire for the cases they were written for.
19210        let signed_out = apply_exhaustion_recovery_hint(InferenceError::InferenceFailed(
19211            "no credential for proprietary provider 'parslee'".into(),
19212        ));
19213        assert!(
19214            matches!(signed_out, InferenceError::InferenceFailed(ref m) if m.contains("car auth")),
19215            "{signed_out:?}"
19216        );
19217
19218        // ...and an unrelated failure still passes through untouched.
19219        let transient = apply_exhaustion_recovery_hint(InferenceError::InferenceFailed(
19220            "API returned 500".into(),
19221        ));
19222        assert_eq!(transient.to_string(), "inference failed: API returned 500");
19223    }
19224
19225    /// Parslee-ai/car#786 — an unconfigured gateway namespace must not reach
19226    /// per-model health.
19227    ///
19228    /// The measured cost of it doing so: ten `parslee/openrouter/*` aliases
19229    /// sitting at 52 calls / 0 successes in `car models stats`, a health record
19230    /// earned entirely by a deployment that had no upstream to proxy to. The
19231    /// models never ran.
19232    #[test]
19233    fn unconfigured_gateway_does_not_trip_circuit_breaker() {
19234        let unconfigured = InferenceError::GatewayUnconfigured {
19235            provider: "parslee".into(),
19236            namespace: "parslee/openrouter/".into(),
19237            status: 503,
19238            message: "OpenRouter inference is not configured on this Parslee environment.".into(),
19239        };
19240        assert!(!error_counts_against_circuit_breaker(&unconfigured));
19241        // The error must still name the namespace and the remedy-relevant
19242        // detail — a caller that cannot tell WHICH namespace died learns
19243        // nothing the generic failure did not already tell them.
19244        let rendered = unconfigured.to_string();
19245        assert!(rendered.contains("parslee/openrouter/"), "{rendered}");
19246        assert!(rendered.contains("not configured"), "{rendered}");
19247    }
19248
19249    /// The namespace drop must be exact-prefix, not "anything mentioning
19250    /// parslee". Dropping `parslee/reasoning` on an OpenRouter-namespace
19251    /// failure would remove working models from the chain — the usable
19252    /// remainder in car#786 was precisely `parslee/advisor`,
19253    /// `parslee/reasoning`, and `parslee/fast`.
19254    #[test]
19255    fn namespace_drop_spares_siblings_outside_the_prefix() {
19256        let namespace = "parslee/openrouter/";
19257        let mut queue: std::collections::VecDeque<String> = [
19258            "parslee/openrouter/open-fast",
19259            "parslee/reasoning",
19260            "parslee/openrouter/frontier-general",
19261            "parslee/advisor",
19262            "anthropic/claude-opus-4-8:latest",
19263        ]
19264        .into_iter()
19265        .map(String::from)
19266        .collect();
19267
19268        queue.retain(|id| !id.starts_with(namespace));
19269
19270        assert_eq!(
19271            queue.iter().collect::<Vec<_>>(),
19272            vec![
19273                "parslee/reasoning",
19274                "parslee/advisor",
19275                "anthropic/claude-opus-4-8:latest"
19276            ],
19277            "only the unconfigured namespace may be dropped"
19278        );
19279    }
19280
19281    #[test]
19282    fn engine_loads_benchmark_priors_on_startup() {
19283        let _env = ENV_MUTEX.blocking_lock();
19284        let tmp = TempDir::new().unwrap();
19285        let priors_path = tmp.path().join("benchmark_priors.json");
19286        std::fs::write(
19287            &priors_path,
19288            serde_json::json!({
19289                "model_id": "qwen/qwen3-8b:q4_k_m",
19290                "overall_score": 0.88
19291            })
19292            .to_string(),
19293        )
19294        .unwrap();
19295
19296        unsafe {
19297            std::env::set_var("CAR_BENCHMARK_PRIORS_PATH", &priors_path);
19298        }
19299
19300        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19301        let tracker = engine.outcome_tracker.blocking_read();
19302        let profile = tracker
19303            .profile("qwen/qwen3-8b:q4_k_m")
19304            .expect("benchmark prior should create a profile");
19305        assert!((profile.ema_quality - 0.88).abs() < 0.01);
19306
19307        unsafe {
19308            std::env::remove_var("CAR_BENCHMARK_PRIORS_PATH");
19309        }
19310    }
19311
19312    #[test]
19313    fn benchmark_priors_do_not_override_observed_profiles() {
19314        let _env = ENV_MUTEX.blocking_lock();
19315        let tmp = TempDir::new().unwrap();
19316        let models_dir = tmp.path().join("models");
19317        std::fs::create_dir_all(&models_dir).unwrap();
19318
19319        let observed = vec![ModelProfile {
19320            model_id: "qwen/qwen3-8b:q4_k_m".into(),
19321            total_calls: 12,
19322            success_count: 3,
19323            fail_count: 9,
19324            total_latency_ms: 1200,
19325            total_input_tokens: 0,
19326            total_output_tokens: 0,
19327            total_cache_read_input_tokens: 0,
19328            total_cache_creation_input_tokens: 0,
19329            task_stats: std::collections::HashMap::new(),
19330            ema_quality: 0.21,
19331            prior_sample_size: 0,
19332            quality_observations: 0,
19333            quality_per_1k_tokens: 0.0,
19334            updated_at: 1,
19335        }];
19336        std::fs::write(
19337            models_dir.join("outcome_profiles.json"),
19338            serde_json::to_string(&observed).unwrap(),
19339        )
19340        .unwrap();
19341
19342        let priors_path = tmp.path().join("benchmark_priors.json");
19343        std::fs::write(
19344            &priors_path,
19345            serde_json::json!({
19346                "model_id": "qwen/qwen3-8b:q4_k_m",
19347                "overall_score": 0.95
19348            })
19349            .to_string(),
19350        )
19351        .unwrap();
19352
19353        unsafe {
19354            std::env::set_var("CAR_BENCHMARK_PRIORS_PATH", &priors_path);
19355        }
19356
19357        let engine = InferenceEngine::new(test_config(models_dir));
19358        let tracker = engine.outcome_tracker.blocking_read();
19359        let profile = tracker
19360            .profile("qwen/qwen3-8b:q4_k_m")
19361            .expect("observed profile should remain present");
19362        assert!((profile.ema_quality - 0.21).abs() < 0.01);
19363        assert_eq!(profile.total_calls, 12);
19364
19365        unsafe {
19366            std::env::remove_var("CAR_BENCHMARK_PRIORS_PATH");
19367        }
19368    }
19369
19370    #[test]
19371    fn speech_runtime_package_spec_defaults_and_overrides() {
19372        let _env = ENV_MUTEX.blocking_lock();
19373        unsafe {
19374            std::env::remove_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC");
19375        }
19376        assert_eq!(speech_runtime_mlx_audio_spec(), "mlx-audio==0.4.2");
19377
19378        unsafe {
19379            std::env::set_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC", "mlx-audio==0.4.1");
19380        }
19381        assert_eq!(speech_runtime_mlx_audio_spec(), "mlx-audio==0.4.1");
19382
19383        unsafe {
19384            std::env::remove_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC");
19385        }
19386    }
19387
19388    #[test]
19389    fn speech_runtime_spacy_model_spec_defaults_and_overrides() {
19390        let _env = ENV_MUTEX.blocking_lock();
19391        unsafe {
19392            std::env::remove_var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC");
19393        }
19394        assert!(
19395            speech_runtime_spacy_model_spec().starts_with("en-core-web-sm @ https://github.com/")
19396        );
19397
19398        unsafe {
19399            std::env::set_var(
19400                "CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC",
19401                "en-core-web-sm==3.8.0",
19402            );
19403        }
19404        assert_eq!(speech_runtime_spacy_model_spec(), "en-core-web-sm==3.8.0");
19405
19406        unsafe {
19407            std::env::remove_var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC");
19408        }
19409    }
19410
19411    #[test]
19412    fn kokoro_runtime_fallback_defaults_on() {
19413        unsafe {
19414            std::env::remove_var("CAR_SPEECH_KOKORO_FALLBACK");
19415        }
19416        assert!(kokoro_runtime_fallback_enabled());
19417
19418        unsafe {
19419            std::env::set_var("CAR_SPEECH_KOKORO_FALLBACK", "false");
19420        }
19421        assert!(!kokoro_runtime_fallback_enabled());
19422
19423        unsafe {
19424            std::env::remove_var("CAR_SPEECH_KOKORO_FALLBACK");
19425        }
19426    }
19427
19428    #[test]
19429    fn preferred_local_tts_wins_over_builtin_rank() {
19430        let tmp = TempDir::new().unwrap();
19431        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19432        engine.set_speech_policy(SpeechPolicy {
19433            prefer_local: true,
19434            allow_remote_fallback: false,
19435            preferred_local_stt: None,
19436            preferred_local_tts: Some("Kokoro-82M-6bit".into()),
19437            preferred_remote_stt: None,
19438            preferred_remote_tts: None,
19439        });
19440
19441        let schema = engine
19442            .preferred_speech_schema(ModelCapability::TextToSpeech, true, false)
19443            .expect("preferred local TTS should resolve");
19444        // On macOS/Linux the MLX Kokoro is available (or tied-unavailable with the
19445        // other local TTS), so the policy preference beats the builtin bf16>6bit
19446        // rank — the property under test. On Windows the MLX models are
19447        // unavailable while the OS synthesizer (Windows-Speech) is available, and
19448        // availability precedes policy in the sort: an unavailable *preferred*
19449        // model correctly yields to one that actually runs.
19450        #[cfg(not(target_os = "windows"))]
19451        assert_eq!(schema.name, "Kokoro-82M-6bit");
19452        #[cfg(target_os = "windows")]
19453        assert_eq!(schema.name, "Windows-Speech");
19454    }
19455
19456    #[test]
19457    fn preferred_discovered_vllm_mlx_model_wins_generate_routing() {
19458        let tmp = TempDir::new().unwrap();
19459        let mut config = test_config(tmp.path().join("models"));
19460        config.preferred_generation_model =
19461            Some("vllm-mlx/mlx-community_gemma-3n-E2B-it-lm-4bit".into());
19462        let mut engine = InferenceEngine::new(config);
19463        let schema = crate::vllm_mlx::to_model_schema(
19464            &crate::vllm_mlx::DiscoveredModel {
19465                id: "mlx-community/gemma-3n-E2B-it-lm-4bit".into(),
19466                owned_by: Some("mlx-community".into()),
19467            },
19468            "http://127.0.0.1:8001",
19469        );
19470        engine.register_model(schema);
19471
19472        let rt = tokio::runtime::Runtime::new().unwrap();
19473        let decision = rt.block_on(engine.route_adaptive("say hello in one sentence"));
19474        assert_eq!(
19475            decision.model_id,
19476            "vllm-mlx/mlx-community_gemma-3n-E2B-it-lm-4bit"
19477        );
19478        assert_eq!(decision.strategy, RoutingStrategy::Explicit);
19479        assert_eq!(decision.reason, "preferred generation model override");
19480    }
19481
19482    /// Regression (I4 review, critical 1): the fallback loop was converted
19483    /// from `for` to an index-based `while` whose increment a pre-existing
19484    /// `continue` (e.g. the ToolUse capability guard) skipped — retrying
19485    /// the SAME candidate forever at 100% CPU. The loop is now a pop-front
19486    /// queue, so `continue` always moves on. This pins termination: a
19487    /// tools request routed to a model without ToolUse must RETURN (the
19488    /// capability guard fires, the queue drains, all-models-failed), not
19489    /// hang. Under the buggy loop this test times out.
19490    #[test]
19491    fn tools_request_on_non_tool_model_terminates_not_spins() {
19492        let tmp = TempDir::new().unwrap();
19493        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19494        // "embed" in the name → capabilities [Embed] only (no ToolUse).
19495        let schema = crate::vllm_mlx::to_model_schema(
19496            &crate::vllm_mlx::DiscoveredModel {
19497                id: "test-org/embed-only-model".into(),
19498                owned_by: None,
19499            },
19500            "http://127.0.0.1:1", // nothing listens; must not matter
19501        );
19502        let model_id = schema.id.clone();
19503        engine.register_model(schema);
19504
19505        let mut req = GenerateRequest {
19506            prompt: "call a tool".to_string(),
19507            model: Some(model_id),
19508            tools: Some(vec![serde_json::json!({
19509                "name": "noop", "description": "n", "parameters": {"type": "object"}
19510            })]),
19511            ..Default::default()
19512        };
19513        req.params.strict_model = true;
19514
19515        let rt = tokio::runtime::Runtime::new().unwrap();
19516        let out = rt.block_on(async {
19517            tokio::time::timeout(
19518                std::time::Duration::from_secs(10),
19519                engine.generate_tracked(req),
19520            )
19521            .await
19522        });
19523        // The point is termination; the result is an error (no capable
19524        // model), which is fine.
19525        let completed = out.expect("fallback loop must terminate, not spin");
19526        assert!(completed.is_err());
19527    }
19528
19529    /// Lay down a runtime root that [`SpeechRuntime::is_ready`] accepts, so
19530    /// `prepare_speech_runtime` short-circuits instead of shelling out to `uv`
19531    /// for a real (multi-minute, network-bound) venv + pip install.
19532    ///
19533    /// The interpreter half comes from [`managed_venv::seed_ready_venv`] — one
19534    /// definition, shared with `car-cli`'s CLI tests — because readiness
19535    /// *executes* the interpreter and the runnable-stub trick differs per
19536    /// platform. The console scripts are only stat-ed, so empty files at the
19537    /// paths `SpeechRuntime` itself computes are enough; taking them from the
19538    /// struct is what keeps fixture and probe from drifting apart again.
19539    fn fake_ready_speech_runtime(root: &Path) {
19540        managed_venv::seed_ready_venv(root);
19541        let runtime = SpeechRuntime::new(root.to_path_buf());
19542        for program in [&runtime.stt_program, &runtime.tts_program] {
19543            std::fs::create_dir_all(program.parent().expect("program has a parent")).unwrap();
19544            std::fs::write(program, b"").unwrap();
19545        }
19546    }
19547
19548    /// Parslee-ai/car#649 — `speech install` and `speech doctor` contradicted
19549    /// each other on Apple Silicon: prepare returned `models_dir` (a path
19550    /// doctor never mentions) after skipping provisioning entirely, so install
19551    /// printed "ready" while doctor printed `Installed: no` against a different
19552    /// root. Prepare must hand back exactly the root doctor reports, and that
19553    /// root must exist (Parslee-ai/car#626 — "prepare" leaves the thing
19554    /// prepared).
19555    ///
19556    /// Unscoped by cfg on purpose: the whole point of the fix is that both
19557    /// branches now agree on one root. The pre-seeded runtime keeps `uv` out of
19558    /// it, which is what previously forced this test to be macOS-only.
19559    ///
19560    /// Keeping it unscoped is also what caught the Windows layout bug. When the
19561    /// probe read `<root>/bin/python` on every platform, this test's pre-seeded
19562    /// runtime went unrecognised on Windows, prepare fell through to a real
19563    /// `uv` bootstrap, and CI panicked with "`uv` … was not found on PATH".
19564    ///
19565    /// #953 gated this `#[cfg(unix)]` to unbreak the Windows leg, and named the
19566    /// real repair in the same breath: "Making it spawn means teaching
19567    /// `interpreter` the Windows `Scripts\\python.exe` layout … That is a
19568    /// product change, not a test fix." That product change is now made, so the
19569    /// gate comes back off. It has to: `managed_venv`'s claim that these Python
19570    /// stacks are Apple-Silicon-only holds for the *visual* runtime and is
19571    /// backwards for the speech one, which exists precisely for machines
19572    /// without Apple's MLX backends. Gate this and the runtime loses coverage
19573    /// on its own target platform.
19574    #[tokio::test]
19575    async fn prepare_speech_runtime_returns_the_root_doctor_reports() {
19576        let _env = ENV_MUTEX.lock().await;
19577        let tmp = TempDir::new().unwrap();
19578        let runtime_root = tmp.path().join("speech-runtime");
19579        fake_ready_speech_runtime(&runtime_root);
19580        unsafe {
19581            std::env::set_var("CAR_SPEECH_RUNTIME_DIR", &runtime_root);
19582        }
19583
19584        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19585        let root = engine
19586            .prepare_speech_runtime()
19587            .await
19588            .expect("prepare should succeed against a ready runtime");
19589        let health = engine.speech_health();
19590
19591        assert_eq!(
19592            root, health.runtime.root,
19593            "prepare returned a different root than doctor reports"
19594        );
19595        assert!(
19596            root.exists(),
19597            "prepare returned {} but it does not exist",
19598            root.display()
19599        );
19600        assert!(
19601            health.runtime.installed,
19602            "doctor should report a ready runtime as installed"
19603        );
19604        // Idempotent — a second call on a provisioned runtime is fine.
19605        assert_eq!(
19606            engine
19607                .prepare_speech_runtime()
19608                .await
19609                .expect("second prepare should succeed"),
19610            root
19611        );
19612
19613        unsafe {
19614            std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
19615        }
19616    }
19617
19618    /// Parslee-ai/car#649 — on Apple Silicon the managed runtime is a *fallback*
19619    /// behind working native MLX backends, so a machine without `uv` must still
19620    /// get a usable install: prepare degrades (warns, returns the created root)
19621    /// instead of failing, and doctor is left to report the truth. Elsewhere the
19622    /// managed runtime is the only local speech path and the error propagates.
19623    ///
19624    /// The bogus `CAR_SPEECH_PYTHON` makes the bootstrap fail immediately —
19625    /// `uv venv --python <nonexistent>` cannot resolve an interpreter — so this
19626    /// never runs a real provision, whether or not `uv` is on PATH.
19627    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
19628    #[tokio::test]
19629    async fn prepare_speech_runtime_degrades_when_bootstrap_fails() {
19630        let _env = ENV_MUTEX.lock().await;
19631        let tmp = TempDir::new().unwrap();
19632        let runtime_root = tmp.path().join("speech-runtime");
19633        assert!(!runtime_root.exists(), "precondition: root absent");
19634        unsafe {
19635            std::env::set_var("CAR_SPEECH_RUNTIME_DIR", &runtime_root);
19636            std::env::set_var("CAR_SPEECH_PYTHON", tmp.path().join("no-such-python"));
19637        }
19638
19639        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19640        let root = engine
19641            .prepare_speech_runtime()
19642            .await
19643            .expect("a failed bootstrap must degrade, not fail, on Apple Silicon");
19644        let health = engine.speech_health();
19645
19646        assert_eq!(root, health.runtime.root);
19647        assert!(
19648            root.exists(),
19649            "prepare returned {} but it does not exist",
19650            root.display()
19651        );
19652        assert!(
19653            !health.runtime.installed,
19654            "doctor must not claim a runtime the bootstrap never built"
19655        );
19656
19657        unsafe {
19658            std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
19659            std::env::remove_var("CAR_SPEECH_PYTHON");
19660        }
19661    }
19662
19663    /// car#678: off Apple Silicon the managed mlx-audio runtime cannot be
19664    /// built (`mlx` publishes no Windows/Linux wheels, and `uv` is often
19665    /// absent), and `install_curated_speech` propagated that failure with `?`.
19666    /// So `car speech install` — the command `car speech doctor` explicitly
19667    /// tells those users to run — aborted before the whisper.cpp block, and
19668    /// never fetched the one local speech model that does run there.
19669    ///
19670    /// Linux-only, and deliberately so on both counts. It is a platform where
19671    /// the bug is real, unlike Apple Silicon. And the seam is `HOME`: the
19672    /// whisper cache resolves through `dirs::home_dir()`, which honours `HOME`
19673    /// on Linux but reads a known-folder API on Windows, so redirecting the
19674    /// cache — and with it keeping this test offline — only works here.
19675    #[cfg(target_os = "linux")]
19676    #[tokio::test]
19677    async fn a_runtime_that_cannot_be_built_no_longer_blocks_the_whisper_install() {
19678        let _env = ENV_MUTEX.lock().await;
19679        let tmp = TempDir::new().unwrap();
19680
19681        // Pre-seed the whisper cache so `ensure_model` short-circuits. The
19682        // assertion is that the install *reaches* the download, not that it
19683        // performs one — a 574 MB fetch has no place in a unit test.
19684        let cached = tmp
19685            .path()
19686            .join(".tokhn")
19687            .join("whisper")
19688            .join("ggml-large-v3-turbo-q5_0.bin");
19689        std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
19690        std::fs::write(&cached, b"stand-in for the ggml weights").unwrap();
19691
19692        let previous_home = std::env::var_os("HOME");
19693        unsafe {
19694            std::env::set_var("HOME", tmp.path());
19695            std::env::set_var("CAR_SPEECH_RUNTIME_DIR", tmp.path().join("speech-runtime"));
19696            // `uv venv --python <nonexistent>` cannot resolve an interpreter,
19697            // so the bootstrap fails immediately and never provisions for real
19698            // — whether or not `uv` happens to be on PATH.
19699            std::env::set_var("CAR_SPEECH_PYTHON", tmp.path().join("no-such-python"));
19700        }
19701
19702        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19703        let installed = engine.install_curated_speech().await;
19704
19705        let restore = || unsafe {
19706            match &previous_home {
19707                Some(value) => std::env::set_var("HOME", value),
19708                None => std::env::remove_var("HOME"),
19709            }
19710            std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
19711            std::env::remove_var("CAR_SPEECH_PYTHON");
19712        };
19713        let installed = match installed {
19714            Ok(installed) => installed,
19715            Err(error) => {
19716                restore();
19717                panic!("a runtime that cannot be built must not abort the install: {error}");
19718            }
19719        };
19720        let runtime_installed = engine.speech_health().runtime.installed;
19721        restore();
19722
19723        assert!(
19724            !runtime_installed,
19725            "precondition: the bootstrap must actually have failed, or this \
19726             test would pass without exercising anything"
19727        );
19728        let whisper = installed
19729            .iter()
19730            .find(|report| report.hf_repo == "ggerganov/whisper.cpp")
19731            .unwrap_or_else(|| {
19732                panic!(
19733                    "the cross-platform whisper model must still be installed, got: {installed:?}"
19734                )
19735            });
19736        assert_eq!(whisper.snapshot_path, cached);
19737        assert!(
19738            installed
19739                .iter()
19740                .all(|report| report.hf_repo == "ggerganov/whisper.cpp"),
19741            "MLX weights are Apple-only and must not be pulled here — over a \
19742             gigabyte of them, for models this host can never load; got: {installed:?}"
19743        );
19744    }
19745
19746    /// Regression: the non-streaming `generate_tracked` fallback loop must book
19747    /// outcomes against the *resolved canonical id* (`schema.id`), not the raw
19748    /// alias the caller passed. Otherwise an explicit alias like
19749    /// `claude-sonnet-4-6` and the catalog id `anthropic/claude-sonnet-4-6:latest`
19750    /// fragment the model-health surface into two "models" for one physical
19751    /// model. This locks the resolution chain the loop relies on
19752    /// (`get().or_else(find_by_name())` → `schema.id`) against catalog drift.
19753    #[test]
19754    fn alias_resolves_to_canonical_id_for_outcome_keying() {
19755        let tmp = TempDir::new().unwrap();
19756        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19757
19758        // (alias passed by a caller, canonical id the outcome tracker must key on)
19759        let cases = [
19760            ("claude-sonnet-4-6", "anthropic/claude-sonnet-4-6:latest"),
19761            ("gpt-5.4", "openai/gpt-5.4:latest"),
19762            ("gemini-2.5-flash", "google/gemini-2.5-flash:latest"),
19763        ];
19764        for (alias, canonical) in cases {
19765            // Exactly the resolution the fallback loop performs before
19766            // `record_start` (see the `resolved_id` binding in the loop).
19767            let resolved = engine
19768                .unified_registry
19769                .get(alias)
19770                .or_else(|| engine.unified_registry.find_by_name(alias))
19771                .map(|s| s.id.clone())
19772                .unwrap_or_else(|| alias.to_string());
19773            assert_eq!(
19774                resolved, canonical,
19775                "alias `{alias}` must resolve to canonical `{canonical}` for outcome keying, got `{resolved}`"
19776            );
19777            assert_ne!(
19778                resolved, alias,
19779                "alias `{alias}` must NOT be recorded raw — that is the fragmentation bug"
19780            );
19781        }
19782    }
19783
19784    /// Issue #43 — InferenceResult must serialize with all fields preserved
19785    /// (text, tool_calls, trace_id, model_used, latency_ms, usage) using
19786    /// snake_case field names. The car-server WebSocket handler relies on
19787    /// `serde_json::to_value(&InferenceResult)` producing this exact shape.
19788    #[test]
19789    fn inference_result_serializes_with_full_shape() {
19790        use crate::tasks::generate::ToolCall;
19791        use std::collections::HashMap;
19792
19793        let mut args = HashMap::new();
19794        args.insert("path".to_string(), serde_json::json!("README.md"));
19795
19796        let result = InferenceResult {
19797            text: String::new(),
19798            bounding_boxes: Vec::new(),
19799            tool_calls: vec![ToolCall {
19800                id: None,
19801                name: "read_file".into(),
19802                arguments: args,
19803            }],
19804            trace_id: "trace-abc".into(),
19805            model_used: "test-model".into(),
19806            model_identity: InferenceModelIdentity {
19807                requested_model_id: Some("test/model:1".into()),
19808                resolved_model_id: "test/model:1".into(),
19809                row_digest: "a".repeat(64),
19810                catalog_revision: "b".repeat(64),
19811            },
19812            latency_ms: 1234,
19813            time_to_first_token_ms: Some(180),
19814            usage: Some(TokenUsage {
19815                prompt_tokens: 100,
19816                completion_tokens: 50,
19817                total_tokens: 150,
19818                context_window: 8192,
19819                ..Default::default()
19820            }),
19821            provider_output_items: Vec::new(),
19822            thinking: Vec::new(),
19823            stop_reason: Some("tool_use".into()),
19824            auth_fallback_from: None,
19825            local_last_resort: false,
19826            fallback_from: Vec::new(),
19827        };
19828
19829        let json = serde_json::to_value(&result).expect("serialize");
19830
19831        // stop_reason propagates through serialization when populated.
19832        assert_eq!(json["stop_reason"].as_str(), Some("tool_use"));
19833
19834        // Required snake_case fields with type-strict assertions
19835        assert_eq!(json["text"].as_str(), Some(""));
19836        assert_eq!(json["trace_id"].as_str(), Some("trace-abc"));
19837        assert_eq!(json["model_used"].as_str(), Some("test-model"));
19838        assert_eq!(json["requested_model_id"].as_str(), Some("test/model:1"));
19839        assert_eq!(json["resolved_model_id"].as_str(), Some("test/model:1"));
19840        assert_eq!(json["row_digest"].as_str().unwrap(), "a".repeat(64));
19841        assert_eq!(json["catalog_revision"].as_str().unwrap(), "b".repeat(64));
19842        assert_eq!(json["latency_ms"].as_u64(), Some(1234));
19843
19844        // tool_calls is a non-empty array with name + arguments
19845        let tool_calls = json["tool_calls"].as_array().expect("tool_calls array");
19846        assert_eq!(tool_calls.len(), 1);
19847        assert_eq!(tool_calls[0]["name"].as_str(), Some("read_file"));
19848        assert_eq!(
19849            tool_calls[0]["arguments"]["path"].as_str(),
19850            Some("README.md")
19851        );
19852
19853        // usage is an object with all four documented fields
19854        let usage = &json["usage"];
19855        assert_eq!(usage["prompt_tokens"].as_u64(), Some(100));
19856        assert_eq!(usage["completion_tokens"].as_u64(), Some(50));
19857        assert_eq!(usage["total_tokens"].as_u64(), Some(150));
19858        assert_eq!(usage["context_window"].as_u64(), Some(8192));
19859
19860        // TTFT propagates through serialization when populated.
19861        assert_eq!(json["time_to_first_token_ms"].as_u64(), Some(180));
19862    }
19863
19864    /// Issue #43 — Lock the top-level WebSocket `infer` response contract.
19865    /// If a future change adds a field to `InferenceResult`, this test forces
19866    /// the developer to deliberately update the protocol surface and the
19867    /// expected key set here, rather than silently leaking new fields onto
19868    /// the wire.
19869    #[test]
19870    fn inference_result_top_level_keys_are_locked() {
19871        use std::collections::BTreeSet;
19872
19873        let result = InferenceResult {
19874            text: "anything".into(),
19875            bounding_boxes: Vec::new(),
19876            tool_calls: vec![],
19877            trace_id: "t".into(),
19878            model_used: "m".into(),
19879            model_identity: InferenceModelIdentity::default(),
19880            latency_ms: 0,
19881            time_to_first_token_ms: None,
19882            usage: None,
19883            provider_output_items: Vec::new(),
19884            thinking: Vec::new(),
19885            stop_reason: None,
19886            auth_fallback_from: None,
19887            local_last_resort: false,
19888            fallback_from: Vec::new(),
19889        };
19890
19891        let json = serde_json::to_value(&result).expect("serialize");
19892        let keys: BTreeSet<&str> = json
19893            .as_object()
19894            .expect("top-level object")
19895            .keys()
19896            .map(String::as_str)
19897            .collect();
19898
19899        let expected: BTreeSet<&str> = [
19900            "text",
19901            "tool_calls",
19902            "trace_id",
19903            "model_used",
19904            "requested_model_id",
19905            "resolved_model_id",
19906            "row_digest",
19907            "catalog_revision",
19908            "latency_ms",
19909            "time_to_first_token_ms",
19910            "usage",
19911            "stop_reason",
19912        ]
19913        .into_iter()
19914        .collect();
19915
19916        assert_eq!(
19917            keys, expected,
19918            "infer response top-level keys drifted -- update both the test \
19919             and the WebSocket protocol documentation if this is intentional"
19920        );
19921
19922        // All keys are snake_case (constraint c-2 in outcome 043).
19923        for key in &keys {
19924            assert!(
19925                !key.chars().any(|c| c.is_uppercase()) && !key.contains('-'),
19926                "key '{}' is not snake_case",
19927                key
19928            );
19929        }
19930    }
19931
19932    /// Plain text result (no tools) must still serialize cleanly with text
19933    /// populated and tool_calls present as an empty array. Backward compat
19934    /// for clients that only care about `.text`.
19935    #[test]
19936    fn inference_result_serializes_plain_text_response() {
19937        let result = InferenceResult {
19938            text: "hello world".into(),
19939            bounding_boxes: Vec::new(),
19940            tool_calls: vec![],
19941            trace_id: "trace-xyz".into(),
19942            model_used: "test-model".into(),
19943            model_identity: InferenceModelIdentity::default(),
19944            latency_ms: 42,
19945            time_to_first_token_ms: None,
19946            usage: None,
19947            provider_output_items: Vec::new(),
19948            thinking: Vec::new(),
19949            stop_reason: None,
19950            auth_fallback_from: None,
19951            local_last_resort: false,
19952            fallback_from: Vec::new(),
19953        };
19954
19955        let json = serde_json::to_value(&result).expect("serialize");
19956        assert_eq!(json["text"], "hello world");
19957        // Always-present null when the provider didn't report one.
19958        assert!(json["stop_reason"].is_null());
19959        assert!(json["tool_calls"].is_array());
19960        assert_eq!(json["tool_calls"].as_array().unwrap().len(), 0);
19961        assert_eq!(json["model_used"], "test-model");
19962        assert!(json["usage"].is_null());
19963        // Honest "wasn't measured" rather than missing key — the field
19964        // is always present at the protocol surface.
19965        assert!(json["time_to_first_token_ms"].is_null());
19966    }
19967
19968    #[test]
19969    fn append_assistant_history_preserves_responses_items_in_provider_order() {
19970        let reasoning = serde_json::json!({
19971            "type": "reasoning",
19972            "id": "rs_history",
19973            "status": "completed",
19974            "summary": [{"type": "summary_text", "text": "safe"}],
19975            "encrypted_content": "opaque",
19976        });
19977        let result: InferenceResult = serde_json::from_value(serde_json::json!({
19978            "text": "calling",
19979            "tool_calls": [{
19980                "id": "call_1",
19981                "name": "read_file",
19982                "arguments": {"path": "README.md"}
19983            }],
19984            "trace_id": "trace",
19985            "model_used": "gateway-alias",
19986            "resolved_model_id": "openrouter/anthropic/claude-sonnet-4.5",
19987            "local_last_resort": true,
19988            "latency_ms": 1,
19989            "provider_output_items": [reasoning.clone()],
19990        }))
19991        .unwrap();
19992        let mut history = vec![crate::tasks::generate::Message::User {
19993            content: "inspect".into(),
19994        }];
19995
19996        result.append_assistant_history(&mut history, result.tool_calls.clone());
19997
19998        assert!(matches!(
19999            &history[1],
20000            crate::tasks::generate::Message::ProviderOutputItems { protocol, items }
20001                if protocol == crate::protocol::OPENAI_RESPONSES_PROTOCOL
20002                    && items == &vec![reasoning]
20003        ));
20004        assert!(matches!(
20005            &history[2],
20006            crate::tasks::generate::Message::Assistant {
20007                content,
20008                tool_calls,
20009                model_id,
20010                local_last_resort,
20011                ..
20012            } if content == "calling"
20013                && tool_calls[0].id.as_deref() == Some("call_1")
20014                && model_id.as_deref() == Some("openrouter/anthropic/claude-sonnet-4.5")
20015                && *local_last_resort
20016        ));
20017    }
20018
20019    #[test]
20020    fn append_assistant_history_leaves_personal_chat_history_unchanged() {
20021        let result: InferenceResult = serde_json::from_value(serde_json::json!({
20022            "text": "plain",
20023            "tool_calls": [],
20024            "trace_id": "trace",
20025            "model_used": "openrouter/openai/gpt-4.1-mini",
20026            "latency_ms": 1,
20027        }))
20028        .unwrap();
20029        let mut history = Vec::new();
20030
20031        result.append_assistant_history(&mut history, Vec::new());
20032
20033        assert_eq!(history.len(), 1);
20034        assert!(matches!(
20035            &history[0],
20036            crate::tasks::generate::Message::Assistant { content, .. } if content == "plain"
20037        ));
20038    }
20039
20040    /// Wire contract — the WebSocket `infer` handler in
20041    /// `car-server-core/src/handler.rs::handle_infer` deserializes
20042    /// the entire `GenerateRequest` from JSON-RPC params via
20043    /// `serde_json::from_value(msg.params.clone())`. That means the
20044    /// `intent` field must remain a serde-deserialize field of
20045    /// `GenerateRequest` for the WS surface to honor caller-supplied
20046    /// routing intent. If a refactor moves intent to a separate
20047    /// argument or renames the field, this test fails and the WS
20048    /// handler must be updated to thread intent explicitly. See
20049    /// `docs/proposals/policy-intent-surface.md` and
20050    /// `docs/websocket-protocol.md` `infer` section.
20051    #[test]
20052    fn generate_request_deserializes_intent_field_from_json_rpc_params() {
20053        use crate::intent::TaskHint;
20054        use crate::schema::ModelCapability;
20055
20056        // Shape mirrors what a WebSocket client sends in the `params`
20057        // object on an `infer` JSON-RPC method call.
20058        let params = serde_json::json!({
20059            "prompt": "summarize this email",
20060            "intent": {
20061                "task": "chat",
20062                "prefer_local": true,
20063                "require": ["tool_use"],
20064            },
20065        });
20066
20067        let req: GenerateRequest =
20068            serde_json::from_value(params).expect("GenerateRequest deserialize");
20069
20070        let intent = req.intent.as_ref().expect("intent field deserialized");
20071        assert_eq!(intent.task, Some(TaskHint::Chat));
20072        assert!(intent.prefer_local);
20073        assert_eq!(intent.require, vec![ModelCapability::ToolUse]);
20074
20075        // Round-trip through serde_json::to_value to confirm the
20076        // re-encoded shape matches what handle_infer would forward to
20077        // the engine without dropping the field.
20078        let back: serde_json::Value =
20079            serde_json::to_value(&req).expect("re-serialize GenerateRequest");
20080        assert_eq!(back["intent"]["task"], "chat");
20081        assert_eq!(back["intent"]["prefer_local"], true);
20082        assert_eq!(back["intent"]["require"][0], "tool_use");
20083
20084        // Default `IntentHint` (no fields set) maps to the no-intent
20085        // path and must serialize as bare `{}` so missing-keys clients
20086        // see a stable default — same guarantee `intent.rs::tests` has
20087        // for the type itself, repeated here at the request boundary.
20088        let default_req: GenerateRequest = serde_json::from_value(serde_json::json!({
20089            "prompt": "x",
20090            "intent": {},
20091        }))
20092        .unwrap();
20093        let default_intent = default_req.intent.expect("present but empty");
20094        assert_eq!(default_intent.task, None);
20095        assert!(!default_intent.prefer_local);
20096        assert!(default_intent.require.is_empty());
20097
20098        // Missing intent field entirely → `None`, matching pre-intent
20099        // clients exactly. This is the backwards-compat guarantee.
20100        let no_intent: GenerateRequest =
20101            serde_json::from_value(serde_json::json!({"prompt": "x"})).unwrap();
20102        assert!(no_intent.intent.is_none());
20103    }
20104
20105    #[test]
20106    fn rerank_prompt_matches_upstream_template_shape() {
20107        let p = rerank_prompt(
20108            "retrieve relevant passages",
20109            "who runs the treasury?",
20110            "doc x",
20111        );
20112        assert!(p.contains("<|im_start|>system"));
20113        assert!(p.contains("Note that the answer can only be \"yes\" or \"no\"."));
20114        assert!(p.contains("<|im_start|>user\n<Instruct>: retrieve relevant passages"));
20115        assert!(p.contains("<Query>: who runs the treasury?"));
20116        assert!(p.contains("<Document>: doc x<|im_end|>"));
20117        assert!(p.contains("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
20118    }
20119
20120    #[test]
20121    fn rerank_score_yes_and_no_exactly() {
20122        assert_eq!(score_from_rerank_output("yes", "m"), 1.0);
20123        assert_eq!(score_from_rerank_output("no", "m"), 0.0);
20124    }
20125
20126    #[test]
20127    fn rerank_score_handles_case_leading_space_and_chat_sentinels() {
20128        // Real decodes often include leading whitespace, punctuation,
20129        // or chat-template sentinels around the answer token.
20130        assert_eq!(score_from_rerank_output(" Yes", "m"), 1.0);
20131        assert_eq!(score_from_rerank_output("\nno.", "m"), 0.0);
20132        assert_eq!(score_from_rerank_output("<|im_end|>yes", "m"), 1.0);
20133    }
20134
20135    #[test]
20136    fn rerank_score_scans_up_to_three_tokens() {
20137        // Tokenizer artifacts can produce a BOS-like leading token
20138        // before the real answer. Don't miss it.
20139        assert_eq!(score_from_rerank_output("_bos_ yes", "m"), 1.0);
20140    }
20141
20142    #[test]
20143    fn rerank_score_unexpected_is_neutral() {
20144        // Plain-base models that aren't reranker-fine-tuned will emit
20145        // arbitrary completion tokens. Don't partition; go neutral.
20146        assert_eq!(score_from_rerank_output("maybe", "m"), 0.5);
20147        assert_eq!(score_from_rerank_output("", "m"), 0.5);
20148    }
20149
20150    #[tokio::test]
20151    async fn pull_reuses_a_valid_directory_receipt_and_reports_removability() {
20152        let root = tempfile::tempdir().unwrap();
20153        let models_dir = root.path().join("models");
20154        std::fs::create_dir_all(&models_dir).unwrap();
20155        let engine = InferenceEngine::new(InferenceConfig {
20156            state_root: root.path().join("state"),
20157            models_dir: models_dir.clone(),
20158            ..InferenceConfig::default()
20159        });
20160        let schema = engine
20161            .unified_registry
20162            .all()
20163            .find(|schema| matches!(schema.source, ModelSource::Local { .. }))
20164            .unwrap()
20165            .clone();
20166        let managed = models_dir.join(&schema.name);
20167        std::fs::create_dir_all(&managed).unwrap();
20168        std::fs::write(managed.join("model.gguf"), b"owned").unwrap();
20169        std::fs::write(managed.join("tokenizer.json"), b"{}").unwrap();
20170        engine
20171            .model_management
20172            .record_managed_artifact(
20173                &schema.id,
20174                model_source_identity(&schema),
20175                None,
20176                1,
20177                false,
20178                managed.clone(),
20179            )
20180            .unwrap();
20181        let row = engine
20182            .list_models_unified()
20183            .into_iter()
20184            .find(|row| row.id == schema.id)
20185            .unwrap();
20186        if model_management::directory_removal_supported() {
20187            assert!(engine.model_management.can_remove(&schema.id).unwrap());
20188            assert!(row.can_remove);
20189            assert_eq!(row.management_evidence.as_deref(), Some("install_receipt"));
20190        } else {
20191            assert!(!engine.model_management.can_remove(&schema.id).unwrap());
20192            assert!(!row.can_remove);
20193            assert_eq!(
20194                row.management_evidence.as_deref(),
20195                Some("install_receipt_directory_cleanup_unsupported")
20196            );
20197        }
20198
20199        let reused = engine.pull_model(&schema.id).await.unwrap();
20200        assert_eq!(reused, managed);
20201    }
20202
20203    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
20204    #[test]
20205    fn kokoro_cache_is_explicitly_state_root_scoped_and_does_not_pin_runtime() {
20206        let first_root = tempfile::tempdir().unwrap();
20207        let second_root = tempfile::tempdir().unwrap();
20208        let first = scoped_kokoro_backend_cache(first_root.path());
20209        let second = scoped_kokoro_backend_cache(second_root.path());
20210        assert!(!Arc::ptr_eq(first.cache(), second.cache()));
20211        assert!(!Arc::ptr_eq(first.admission(), second.admission()));
20212
20213        let runtime = Arc::downgrade(&first._runtime);
20214        drop(first);
20215        assert!(
20216            runtime.upgrade().is_none(),
20217            "an unused Kokoro accessor must not process-pin the scoped runtime"
20218        );
20219    }
20220}
20221
20222#[cfg(test)]
20223mod response_format_support_tests {
20224    use super::*;
20225
20226    /// The CLI's startup warning asks the engine, and the engine asks the
20227    /// SAME protocol handler the remote path consults — so the answer cannot
20228    /// drift from what a real request would hit.
20229    #[test]
20230    fn rejection_reason_tracks_the_protocol_handler() {
20231        let engine = InferenceEngine::new(Default::default());
20232        let rf = crate::tasks::generate::ResponseFormat::JsonObject;
20233        let models = engine.list_models_unified();
20234        let anthropic = models
20235            .iter()
20236            .find(|m| m.provider.eq_ignore_ascii_case("anthropic"));
20237        if let Some(m) = anthropic {
20238            let reason = engine
20239                .response_format_rejection_reason(&m.id, &rf)
20240                .expect("the Anthropic protocol rejects response_format");
20241            assert!(reason.contains("protocol rejects"), "{reason}");
20242        }
20243        let openrouter = models
20244            .iter()
20245            .find(|m| m.provider.eq_ignore_ascii_case("openrouter"));
20246        if let Some(m) = openrouter {
20247            assert_eq!(
20248                engine.response_format_rejection_reason(&m.id, &rf),
20249                None,
20250                "OpenRouter forwards the format upstream"
20251            );
20252        }
20253        assert!(
20254            anthropic.is_some() || openrouter.is_some(),
20255            "the builtin catalog should list at least one of the two providers this pins"
20256        );
20257        assert_eq!(
20258            engine.response_format_rejection_reason("no/such-model", &rf),
20259            None,
20260            "an unknown model is not a rejection"
20261        );
20262    }
20263}