1pub 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;
66pub(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
92pub 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
129fn 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
161pub 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#[derive(Error, Debug)]
184pub enum InferenceError {
185 #[error("model not found: {0}")]
186 ModelNotFound(String),
187
188 #[error("no eligible model remains after required exclusions: {excluded_models}")]
192 NoEligibleModel { excluded_models: String },
193
194 #[error("model download failed: {0}")]
195 DownloadFailed(String),
196
197 #[error("inference failed: {0}")]
198 InferenceFailed(String),
199
200 #[error("catalog precondition mismatch: {detail}")]
205 CatalogPreconditionMismatch { detail: String },
206
207 #[error("controlled inference termination confirmed")]
212 ControlledTermination,
213
214 #[error(transparent)]
215 ModelManagement(#[from] model_management::ModelManagementError),
216
217 #[error("{recovery}")]
220 LocalResourceBlocked {
221 preflight: resource_policy::LocalLoadPreflight,
222 recovery: String,
223 },
224
225 #[error("transient remote failure after retries (status={status:?}): {message}")]
234 Transient {
235 status: Option<u16>,
236 message: String,
237 },
238
239 #[error("deadline exceeded: the caller's {applied_ms} ms infer deadline elapsed after {elapsed_ms} ms; last attempt: {last_error}")]
247 DeadlineExceeded {
248 applied_ms: u64,
249 elapsed_ms: u64,
250 last_error: String,
251 },
252
253 #[error("mode {mode} not implemented on backend {backend}: {reason}")]
258 UnsupportedMode {
259 mode: &'static str,
260 backend: &'static str,
261 reason: &'static str,
262 },
263
264 #[error("{provider} account rejected the request (HTTP {status}): {message}")]
279 ProviderAccount {
280 provider: String,
281 status: u16,
282 message: String,
283 },
284
285 #[error("no credential for proprietary provider '{provider}' (model {model}): {detail}")]
302 CredentialUnavailable {
303 provider: String,
304 model: String,
305 reason: CredentialFailure,
307 detail: String,
309 },
310
311 #[error("{provider} refused this request on content grounds{}{}: {message}",
331 .kind.as_deref().map(|k| format!(" (type={k}")).unwrap_or_default(),
332 .code.as_deref().map(|c| format!(", code={c})")).unwrap_or_default())]
333 ContentRefused {
334 provider: String,
335 kind: Option<String>,
337 code: Option<String>,
338 message: String,
339 },
340
341 #[error("{provider} gateway has no upstream configured for '{namespace}' (HTTP {status}): {message}")]
359 GatewayUnconfigured {
360 provider: String,
361 namespace: String,
362 status: u16,
363 message: String,
364 },
365
366 #[error("tokenization error: {0}")]
367 TokenizationError(String),
368
369 #[error("device error: {0}")]
370 DeviceError(String),
371
372 #[error("io error: {0}")]
373 Io(#[from] std::io::Error),
374}
375
376impl From<resource_policy::LocalAdmissionError> for InferenceError {
377 fn from(error: resource_policy::LocalAdmissionError) -> Self {
378 let recovery = match error.preflight.verdict {
379 resource_policy::LocalLoadVerdict::DisabledByPolicy => {
380 "Local model loading is disabled by the 0 GB allocation. Increase Local Models RAM in Settings, or choose a remote model. Downloads remain available.".to_string()
381 }
382 resource_policy::LocalLoadVerdict::ExceedsConfiguredCeiling => format!(
383 "This model needs about {} MB for this request, beyond the configured {} MB local-model allocation. Increase the allocation or choose a smaller model.",
384 error.preflight.estimated_incremental_mb,
385 error.preflight.configured_ceiling_mb
386 ),
387 resource_policy::LocalLoadVerdict::InsufficientLiveMemory => format!(
388 "Not enough memory is free to start this model while preserving CAR's {} MB emergency reserve. Close memory-heavy apps or choose a smaller model.",
389 error.preflight.emergency_reserve_mb
390 ),
391 resource_policy::LocalLoadVerdict::LiveMemoryUnknown => {
392 "CAR could not measure live memory. The static allocation fits, but current safety is unknown.".to_string()
393 }
394 resource_policy::LocalLoadVerdict::ModelMaintenance => {
395 "This local model is being removed or maintained. Wait for that operation to finish, then retry.".to_string()
396 }
397 resource_policy::LocalLoadVerdict::PendingTeardown => {
398 "CAR is still confirming that the previous local model process exited. Wait for teardown to finish, then retry.".to_string()
399 }
400 resource_policy::LocalLoadVerdict::Allowed => error.to_string(),
401 };
402 Self::LocalResourceBlocked {
403 preflight: error.preflight,
404 recovery,
405 }
406 }
407}
408
409fn error_counts_against_circuit_breaker(e: &InferenceError) -> bool {
432 !matches!(
433 e,
434 InferenceError::UnsupportedMode { .. }
435 | InferenceError::ProviderAccount { .. }
436 | InferenceError::GatewayUnconfigured { .. }
437 | InferenceError::ContentRefused { .. }
443 | InferenceError::CatalogPreconditionMismatch { .. }
444 | InferenceError::ControlledTermination
445 )
446}
447
448fn error_ends_fallback_chain(e: &InferenceError) -> bool {
470 matches!(
471 e,
472 InferenceError::ContentRefused { .. }
473 | InferenceError::CatalogPreconditionMismatch { .. }
474 | InferenceError::ControlledTermination
475 )
476}
477
478fn apply_exhaustion_recovery_hint(underlying: InferenceError) -> InferenceError {
489 if matches!(
490 underlying,
491 InferenceError::ContentRefused { .. }
492 | InferenceError::CatalogPreconditionMismatch { .. }
493 | InferenceError::ControlledTermination
494 ) {
495 return underlying;
496 }
497 let underlying_str = underlying.to_string();
498 match no_backend_recovery_hint(&underlying_str)
499 .or_else(|| auth_expired_recovery_hint(&underlying_str))
500 {
501 Some(msg) => InferenceError::InferenceFailed(msg),
502 None => underlying,
503 }
504}
505
506const AUTH_LOGIN_MARKER: &str = "auth login";
507const AUTH_STORE_UNREADABLE_MARKER: &str = "credential store unreadable";
508const AUTH_ENV_MISSING_MARKER: &str = "credential environment variable missing";
509
510const AUTH_FAILURE_MESSAGE_MARKERS: &[&str] = &[
515 "no credential for proprietary",
516 AUTH_LOGIN_MARKER,
517 "session has expired",
518 "cannot read parslee credentials",
519 AUTH_STORE_UNREADABLE_MARKER,
520 AUTH_ENV_MISSING_MARKER,
521 "credential was rejected",
527 "repair its provider login",
528];
529
530#[derive(Debug, Clone, PartialEq, Eq)]
540struct RouteCredentialFailure {
541 summary: String,
542 source_error: String,
543}
544
545fn parslee_signed_out_route_failure() -> RouteCredentialFailure {
546 let summary = "Parslee login is absent — run `car auth login` before retrying".to_string();
547 RouteCredentialFailure {
548 source_error: summary.clone(),
549 summary,
550 }
551}
552
553fn route_credential_failure(
554 candidate: &str,
555 error: &InferenceError,
556 promote_missing_credential: bool,
557) -> Option<String> {
558 match error {
559 InferenceError::CredentialUnavailable {
560 provider,
561 reason,
562 detail,
563 ..
564 } => {
565 let provider_name = if provider.eq_ignore_ascii_case("parslee") {
566 "Parslee".to_string()
567 } else {
568 provider.clone()
569 };
570 let summary = match reason {
571 CredentialFailure::Expired { .. } => format!(
572 "{provider_name} login expired for `{candidate}` — run `car auth login`"
573 ),
574 CredentialFailure::SignedOut => format!(
575 "{provider_name} login is absent for `{candidate}` — run `car auth login`"
576 ),
577 CredentialFailure::StoreUnreadable => format!(
578 "{provider_name} {AUTH_STORE_UNREADABLE_MARKER} for `{candidate}` — unlock the credential store, then retry"
579 ),
580 CredentialFailure::EnvVarMissing { .. } if !promote_missing_credential => {
581 return None;
582 }
583 CredentialFailure::EnvVarMissing { env_var } => format!(
584 "{provider_name} {AUTH_ENV_MISSING_MARKER}: `{env_var}` for explicitly requested `{candidate}` — {detail}"
585 ),
586 CredentialFailure::RaceRetryable => return None,
589 };
590 Some(summary)
591 }
592 InferenceError::ProviderAccount {
593 provider, status, ..
594 } if matches!(*status, 401 | 403) => {
595 if provider.eq_ignore_ascii_case("parslee") {
596 Some(format!(
597 "Parslee login expired or was rejected for `{candidate}` — run `car auth login`"
598 ))
599 } else {
600 Some(format!(
601 "{provider} credential was rejected for `{candidate}` (HTTP {status})"
602 ))
603 }
604 }
605 _ => {
606 let rendered = error.to_string();
607 let lower = rendered.to_ascii_lowercase();
608 if is_auth_rejection_message(&rendered) {
609 if candidate
610 .split_once('/')
611 .is_some_and(|(provider, _)| provider.eq_ignore_ascii_case("parslee"))
612 {
613 Some(format!(
614 "Parslee login expired or was rejected for `{candidate}` — run `car auth login`"
615 ))
616 } else {
617 Some(format!(
618 "credential expired or was rejected for `{candidate}` — repair its provider login"
619 ))
620 }
621 } else if lower.contains(AUTH_STORE_UNREADABLE_MARKER)
622 || (promote_missing_credential && lower.contains("keychain lookup failed"))
623 {
624 Some(format!(
625 "{AUTH_STORE_UNREADABLE_MARKER} for `{candidate}` — unlock the credential store, then retry"
626 ))
627 } else {
628 None
629 }
630 }
631 }
632}
633
634fn record_route_credential_failure(
635 slot: &mut Option<RouteCredentialFailure>,
636 candidate: &str,
637 error: &InferenceError,
638 promote_missing_credential: bool,
639) {
640 if let Some(summary) = route_credential_failure(candidate, error, promote_missing_credential) {
641 *slot = Some(RouteCredentialFailure {
646 summary,
647 source_error: error.to_string(),
648 });
649 }
650}
651
652fn chain_includes_parslee_route<'a>(
657 mut resolve: impl FnMut(&str) -> Option<&'a ModelSchema>,
658 chain: &[String],
659) -> bool {
660 chain.iter().any(|candidate| {
661 resolve(candidate).is_some_and(|schema| schema.provider.eq_ignore_ascii_case("parslee"))
662 })
663}
664
665fn apply_route_failure_context(
685 underlying: InferenceError,
686 credential: Option<&RouteCredentialFailure>,
687) -> InferenceError {
688 if matches!(
689 underlying,
690 InferenceError::ContentRefused { .. }
691 | InferenceError::CatalogPreconditionMismatch { .. }
692 | InferenceError::ControlledTermination
693 ) {
694 return underlying;
695 }
696 let Some(credential) = credential else {
697 return apply_exhaustion_recovery_hint(underlying);
698 };
699 let rendered = underlying.to_string();
700 let summary = credential.summary.as_str();
701 let setup_hint = no_backend_recovery_hint(&rendered);
702 let connective = if rendered == credential.source_error {
703 "provider detail"
704 } else {
705 "fallback then failed"
706 };
707 let augment = |field: String| match &setup_hint {
708 Some(hint) => format!("{summary}; {hint}"),
710 None => format!("{summary}; {connective}: {field}"),
711 };
712 match underlying {
713 InferenceError::InferenceFailed(message) => {
714 InferenceError::InferenceFailed(augment(message))
715 }
716 InferenceError::Transient { status, message } => InferenceError::Transient {
717 status,
718 message: augment(message),
719 },
720 InferenceError::ProviderAccount {
721 provider,
722 status,
723 message,
724 } => InferenceError::ProviderAccount {
725 provider,
726 status,
727 message: augment(message),
728 },
729 InferenceError::CredentialUnavailable {
730 provider,
731 model,
732 reason,
733 detail,
734 } => InferenceError::CredentialUnavailable {
735 provider,
736 model,
737 reason,
738 detail: augment(detail),
739 },
740 InferenceError::GatewayUnconfigured {
741 provider,
742 namespace,
743 status,
744 message,
745 } => InferenceError::GatewayUnconfigured {
746 provider,
747 namespace,
748 status,
749 message: augment(message),
750 },
751 InferenceError::LocalResourceBlocked {
752 preflight,
753 recovery,
754 } => InferenceError::LocalResourceBlocked {
755 preflight,
756 recovery: augment(recovery),
757 },
758 _ => InferenceError::InferenceFailed(augment(rendered)),
761 }
762}
763
764#[derive(Debug, Clone, PartialEq, Eq)]
773pub enum CredentialFailure {
774 Expired { expires_at: u64 },
781 SignedOut,
784 StoreUnreadable,
788 EnvVarMissing { env_var: String },
790 RaceRetryable,
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797pub enum Device {
798 Cpu,
799 Metal,
800 Cuda(usize), }
802
803impl Device {
804 pub fn auto() -> Self {
812 #[cfg(all(target_os = "macos", feature = "metal"))]
813 {
814 return Device::Metal;
815 }
816 #[cfg(all(
817 any(target_os = "linux", target_os = "windows"),
818 target_arch = "x86_64",
819 not(car_skip_cuda)
820 ))]
821 {
822 return Device::Cuda(0);
823 }
824 #[cfg(not(any(
825 all(target_os = "macos", feature = "metal"),
826 all(
827 any(target_os = "linux", target_os = "windows"),
828 target_arch = "x86_64",
829 not(car_skip_cuda)
830 )
831 )))]
832 {
833 Device::Cpu
834 }
835 }
836}
837
838#[derive(Debug, Clone)]
840pub struct InferenceConfig {
841 pub models_dir: std::path::PathBuf,
847 pub state_root: std::path::PathBuf,
856 pub device: Option<Device>,
858 pub generation_model: String,
860 pub preferred_generation_model: Option<String>,
862 pub embedding_model: String,
864 pub preferred_embedding_model: Option<String>,
866 pub classification_model: String,
868 pub preferred_classification_model: Option<String>,
870}
871
872impl Default for InferenceConfig {
873 fn default() -> Self {
874 let models_dir = default_models_dir();
891
892 let hw = HardwareInfo::detect();
893
894 Self {
895 models_dir,
896 state_root: car_home::root_or_relative(),
897 device: None,
898 generation_model: hw.recommended_model,
899 preferred_generation_model: None,
900 embedding_model: "Qwen3-Embedding-0.6B".to_string(),
901 preferred_embedding_model: None,
902 classification_model: "Qwen3-0.6B".to_string(),
903 preferred_classification_model: None,
904 }
905 }
906}
907
908impl InferenceConfig {
909 pub fn state_models_dir(&self) -> std::path::PathBuf {
918 self.state_root.join("models")
919 }
920}
921
922pub fn default_models_dir() -> std::path::PathBuf {
929 dirs_next()
930 .unwrap_or_else(|| std::path::PathBuf::from("."))
931 .join(".car")
932 .join("models")
933}
934
935fn dirs_next() -> Option<std::path::PathBuf> {
936 std::env::var_os("HOME")
940 .or_else(|| std::env::var_os("USERPROFILE"))
941 .map(std::path::PathBuf::from)
942}
943
944fn model_source_identity(schema: &ModelSchema) -> &str {
945 match &schema.source {
946 ModelSource::Local { hf_repo, .. }
947 | ModelSource::Mlx { hf_repo, .. }
948 | ModelSource::ManagedVllmMlx { hf_repo, .. } => hf_repo,
949 ModelSource::WhisperCpp { model } | ModelSource::CodexCli { model } => model,
950 _ => &schema.id,
951 }
952}
953
954#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
956pub struct TokenUsage {
957 pub prompt_tokens: u64,
967 pub completion_tokens: u64,
969 pub total_tokens: u64,
971 pub context_window: u64,
973 #[serde(default)]
978 pub cache_read_input_tokens: u64,
979 #[serde(default)]
984 pub cache_creation_input_tokens: u64,
985}
986
987#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
989pub struct InferenceModelIdentity {
990 #[serde(default)]
993 pub requested_model_id: Option<String>,
994 #[serde(default)]
996 pub resolved_model_id: String,
997 #[serde(default)]
999 pub row_digest: String,
1000 #[serde(default)]
1002 pub catalog_revision: String,
1003}
1004
1005#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1007pub struct InferenceResult {
1008 pub text: String,
1010 pub tool_calls: Vec<crate::tasks::generate::ToolCall>,
1012 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1019 pub bounding_boxes: Vec<crate::tasks::grounding::BoundingBox>,
1020 pub trace_id: String,
1022 pub model_used: String,
1025 #[serde(flatten)]
1028 pub model_identity: InferenceModelIdentity,
1029 pub latency_ms: u64,
1031 #[serde(default)]
1043 pub time_to_first_token_ms: Option<u64>,
1044 pub usage: Option<TokenUsage>,
1063 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1076 pub provider_output_items: Vec<serde_json::Value>,
1077 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1085 pub thinking: Vec<crate::tasks::generate::ThinkingBlock>,
1086 #[serde(default)]
1102 pub stop_reason: Option<String>,
1103 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub auth_fallback_from: Option<String>,
1113 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1120 pub local_last_resort: bool,
1121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1141 pub fallback_from: Vec<FallbackFrom>,
1142}
1143
1144#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1146pub struct FallbackFrom {
1147 pub candidate: String,
1149 pub reason: FallbackReason,
1150}
1151
1152#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1160#[serde(rename_all = "snake_case")]
1161pub enum FallbackReason {
1162 CredentialRejected,
1171 CredentialAbsent,
1174 RateLimited,
1177 QuotaExhausted,
1183 TimedOut,
1191 Failed,
1197}
1198
1199pub fn classify_fallback_reason(error: &InferenceError) -> FallbackReason {
1232 use FallbackReason as R;
1233 match error {
1234 InferenceError::ProviderAccount { status, .. } => match status {
1237 402 => R::QuotaExhausted,
1238 _ => R::CredentialRejected,
1239 },
1240 InferenceError::CredentialUnavailable { reason, .. } => match reason {
1242 CredentialFailure::Expired { .. } => R::CredentialRejected,
1244 CredentialFailure::SignedOut | CredentialFailure::EnvVarMissing { .. } => {
1246 R::CredentialAbsent
1247 }
1248 CredentialFailure::StoreUnreadable | CredentialFailure::RaceRetryable => R::Failed,
1251 },
1252 InferenceError::Transient {
1253 status: Some(429), ..
1254 } => R::RateLimited,
1255 InferenceError::Transient { status: None, .. } => R::Failed,
1262 InferenceError::Transient { .. } => R::Failed,
1263 InferenceError::DeadlineExceeded { .. } => R::TimedOut,
1266 InferenceError::InferenceFailed(msg) => classify_untyped_failure(msg),
1267 _ => R::Failed,
1268 }
1269}
1270
1271fn classify_untyped_failure(msg: &str) -> FallbackReason {
1279 use FallbackReason as R;
1280 if let Some(status) = parse_api_returned_status(msg) {
1283 return match status {
1284 401 | 403 => R::CredentialRejected,
1285 402 => R::QuotaExhausted,
1286 429 => R::RateLimited,
1287 408 | 504 => R::TimedOut,
1288 _ => R::Failed,
1289 };
1290 }
1291 let l = msg.to_ascii_lowercase();
1292 if l.contains("org lookup failed") {
1295 return if l.contains("http 401") || l.contains("http 403") {
1296 R::CredentialRejected
1297 } else if l.contains("http 429") {
1298 R::RateLimited
1299 } else {
1300 R::Failed
1301 };
1302 }
1303 if l.contains("authentication required")
1304 || l.contains("invalid_grant")
1305 || l.contains("token expired")
1306 {
1307 return R::CredentialRejected;
1308 }
1309 if l.contains("no credential for proprietary") || l.contains("no api key") {
1310 return R::CredentialAbsent;
1311 }
1312 if l.contains("too many requests") || l.contains("rate limit") {
1313 return R::RateLimited;
1314 }
1315 if l.contains("timed out") || l.contains("deadline exceeded") {
1316 return R::TimedOut;
1317 }
1318 R::Failed
1319}
1320
1321pub fn record_fallback_from(hops: &mut Vec<FallbackFrom>, candidate: &str, error: &InferenceError) {
1327 hops.push(FallbackFrom {
1328 candidate: candidate.to_string(),
1329 reason: classify_fallback_reason(error),
1330 });
1331}
1332
1333pub struct TrackedStream {
1340 pub model_used: String,
1342 pub trace_id: String,
1344 pub events: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
1346}
1347
1348struct AbortOnDropTask<T>(Option<tokio::task::JoinHandle<T>>);
1349
1350impl<T> AbortOnDropTask<T> {
1351 async fn join(mut self) -> Result<T, tokio::task::JoinError> {
1352 self.0.take().expect("owned task handle available").await
1353 }
1354}
1355
1356impl<T> Drop for AbortOnDropTask<T> {
1357 fn drop(&mut self) {
1358 if let Some(task) = self.0.take() {
1359 task.abort();
1360 }
1361 }
1362}
1363
1364fn bound_model_identity(
1365 snapshot: &CatalogSnapshot,
1366 requested_model_id: Option<&str>,
1367 resolved_model_id: &str,
1368) -> Result<InferenceModelIdentity, InferenceError> {
1369 let row = snapshot.model_by_exact_id(resolved_model_id).ok_or_else(|| {
1370 InferenceError::InferenceFailed(format!(
1371 "resolved model `{resolved_model_id}` was absent from the catalog snapshot bound to this request"
1372 ))
1373 })?;
1374 Ok(InferenceModelIdentity {
1375 requested_model_id: requested_model_id.map(str::to_string),
1376 resolved_model_id: row.model.id.clone(),
1377 row_digest: row.row_digest.clone(),
1378 catalog_revision: snapshot.catalog_revision.clone(),
1379 })
1380}
1381
1382fn validate_expected_catalog_revision(
1383 req: &GenerateRequest,
1384 snapshot: &CatalogSnapshot,
1385) -> Result<(), InferenceError> {
1386 if let Some(expected) = req.expected_catalog_revision.as_deref() {
1387 if expected != snapshot.catalog_revision {
1388 return Err(InferenceError::CatalogPreconditionMismatch {
1389 detail: format!(
1390 "expected catalog revision {expected}, got {}",
1391 snapshot.catalog_revision
1392 ),
1393 });
1394 }
1395 }
1396 Ok(())
1397}
1398
1399fn validate_expected_catalog_row(
1400 req: &GenerateRequest,
1401 snapshot: &CatalogSnapshot,
1402 resolved_model_id: &str,
1403) -> Result<(), InferenceError> {
1404 let Some(expected) = req.expected_row_digest.as_deref() else {
1405 return Ok(());
1406 };
1407 let row = snapshot
1408 .model_by_exact_id(resolved_model_id)
1409 .ok_or_else(|| InferenceError::CatalogPreconditionMismatch {
1410 detail: format!(
1411 "resolved model `{resolved_model_id}` is absent from the bound catalog snapshot"
1412 ),
1413 })?;
1414 if expected != row.row_digest {
1415 return Err(InferenceError::CatalogPreconditionMismatch {
1416 detail: format!(
1417 "expected row digest {expected} for `{resolved_model_id}`, got {}",
1418 row.row_digest
1419 ),
1420 });
1421 }
1422 Ok(())
1423}
1424
1425const EXACT_MODEL_ID_PREFIX: &str = "\0car-exact-model-id:";
1426
1427pub fn pin_exact_model_id(req: &mut GenerateRequest, model_id: String) -> Result<(), String> {
1430 if req.model.is_some() {
1431 return Err("`model` and `model_id` are mutually exclusive".to_string());
1432 }
1433 if model_id.trim().is_empty() {
1434 return Err("`model_id` must be a non-empty immutable id".to_string());
1435 }
1436 req.model = Some(format!("{EXACT_MODEL_ID_PREFIX}{model_id}"));
1437 req.params.strict_model = true;
1438 Ok(())
1439}
1440
1441pub fn exact_pinned_model_id(req: &GenerateRequest) -> Option<&str> {
1442 req.model
1443 .as_deref()
1444 .and_then(|model| model.strip_prefix(EXACT_MODEL_ID_PREFIX))
1445}
1446
1447fn auto_thinking_budget(
1456 is_code_intent: bool,
1457 is_complex: bool,
1458 supports_thinking: bool,
1459) -> Option<usize> {
1460 if !supports_thinking {
1461 return None;
1462 }
1463 if is_code_intent {
1464 Some(24_000)
1465 } else if is_complex {
1466 Some(8_000)
1467 } else {
1468 None
1469 }
1470}
1471
1472fn is_explicit_code_intent(intent: Option<&intent::IntentHint>) -> bool {
1482 intent.and_then(|h| h.task) == Some(intent::TaskHint::Code)
1483}
1484
1485fn should_append_local_last_resort(chain_has_local: bool, strict_model: bool) -> bool {
1490 !chain_has_local && !strict_model
1491}
1492
1493fn resolved_max_tokens(requested: usize, schema: &ModelSchema) -> usize {
1517 if requested != crate::tasks::generate::DEFAULT_MAX_TOKENS
1518 || schema.decodes_in_process()
1519 || schema.is_codex_cli()
1520 {
1521 return requested;
1522 }
1523 schema.effective_max_output()
1524}
1525
1526const DEFAULT_LOCAL_DECODE_TIMEOUT_SECS: u64 = 300;
1539
1540pub const LOCAL_DECODE_TIMEOUT_STOP_REASON: &str = "local_decode_timeout";
1547
1548#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1556const LOCAL_DECODE_HEARTBEAT_SECS: u64 = 10;
1557
1558fn parse_decode_timeout(raw: Option<&str>) -> Option<std::time::Duration> {
1561 let secs = match raw {
1562 Some(v) => v
1563 .trim()
1564 .parse::<u64>()
1565 .unwrap_or(DEFAULT_LOCAL_DECODE_TIMEOUT_SECS),
1566 None => DEFAULT_LOCAL_DECODE_TIMEOUT_SECS,
1567 };
1568 (secs > 0).then(|| std::time::Duration::from_secs(secs))
1569}
1570
1571#[cfg_attr(
1584 not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
1585 allow(dead_code)
1586)]
1587fn deadline_exceeded(elapsed: std::time::Duration, timeout: Option<std::time::Duration>) -> bool {
1588 timeout.is_some_and(|limit| elapsed >= limit)
1589}
1590
1591#[cfg_attr(
1597 not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
1598 allow(dead_code)
1599)]
1600fn heartbeat_due(
1601 elapsed: std::time::Duration,
1602 last: std::time::Duration,
1603 interval: std::time::Duration,
1604) -> bool {
1605 elapsed.saturating_sub(last) >= interval
1606}
1607
1608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1612enum EmptyPassAction {
1613 RetryWithoutThinking,
1616 FailDecodeCeiling,
1620 Accept,
1622}
1623
1624fn classify_empty_pass(
1632 recover: bool,
1633 stop_reason: Option<&str>,
1634 text: &str,
1635 tool_calls_empty: bool,
1636) -> EmptyPassAction {
1637 if !text.trim().is_empty() || !tool_calls_empty {
1638 return EmptyPassAction::Accept;
1639 }
1640 if stop_reason == Some(LOCAL_DECODE_TIMEOUT_STOP_REASON) {
1641 return EmptyPassAction::FailDecodeCeiling;
1642 }
1643 if recover {
1644 return EmptyPassAction::RetryWithoutThinking;
1645 }
1646 EmptyPassAction::Accept
1647}
1648
1649fn local_decode_timeout() -> Option<std::time::Duration> {
1650 parse_decode_timeout(
1651 std::env::var("CAR_LOCAL_DECODE_TIMEOUT_SECS")
1652 .ok()
1653 .as_deref(),
1654 )
1655}
1656
1657impl InferenceResult {
1658 pub fn served_model_id(&self) -> &str {
1663 if self.model_identity.resolved_model_id.is_empty() {
1664 &self.model_used
1665 } else {
1666 &self.model_identity.resolved_model_id
1667 }
1668 }
1669
1670 pub fn has_tool_calls(&self) -> bool {
1672 !self.tool_calls.is_empty()
1673 }
1674
1675 pub fn was_truncated(&self) -> bool {
1687 matches!(
1688 self.stop_reason.as_deref(),
1689 Some(
1690 "length"
1691 | "max_tokens"
1692 | "max_output_tokens"
1693 | "MAX_TOKENS"
1694 | crate::LOCAL_DECODE_TIMEOUT_STOP_REASON
1695 )
1696 )
1697 }
1698
1699 pub fn append_assistant_history(
1708 &self,
1709 messages: &mut Vec<crate::tasks::generate::Message>,
1710 tool_calls: Vec<crate::tasks::generate::ToolCall>,
1711 ) {
1712 if !self.provider_output_items.is_empty() {
1713 messages.push(crate::tasks::generate::Message::ProviderOutputItems {
1714 protocol: crate::protocol::OPENAI_RESPONSES_PROTOCOL.to_string(),
1715 items: self.provider_output_items.clone(),
1716 });
1717 }
1718 messages.push(crate::tasks::generate::Message::Assistant {
1719 content: self.text.clone(),
1720 tool_calls,
1721 thinking: self.thinking.clone(),
1722 model_id: Some(self.served_model_id().to_string()),
1723 local_last_resort: self.local_last_resort,
1724 });
1725 }
1726}
1727
1728#[derive(Debug, Clone, Serialize)]
1729pub struct SpeechRuntimeHealth {
1730 pub root: PathBuf,
1731 pub installed: bool,
1732 pub python: PathBuf,
1733 pub stt_command: PathBuf,
1734 pub tts_command: PathBuf,
1735 pub configured_python: Option<String>,
1736 pub detected_python: Option<String>,
1737}
1738
1739#[derive(Debug, Clone, Serialize)]
1740pub struct SpeechModelHealth {
1741 pub id: String,
1742 pub name: String,
1743 pub provider: String,
1744 pub capability: ModelCapability,
1745 pub is_local: bool,
1746 pub available: bool,
1747 pub cached: bool,
1748 pub selected_by_default: bool,
1749 pub source: String,
1750}
1751
1752#[derive(Debug, Clone, Serialize)]
1753pub struct SpeechHealthReport {
1754 pub runtime: SpeechRuntimeHealth,
1755 pub local_models: Vec<SpeechModelHealth>,
1756 pub remote_models: Vec<SpeechModelHealth>,
1757 pub elevenlabs_configured: bool,
1758 pub prefer_local: bool,
1759 pub allow_remote_fallback: bool,
1760 pub preferred_local_stt: Option<String>,
1761 pub preferred_local_tts: Option<String>,
1762 pub preferred_remote_stt: Option<String>,
1763 pub preferred_remote_tts: Option<String>,
1764 pub local_stt_default: Option<String>,
1765 pub local_tts_default: Option<String>,
1766 pub remote_stt_default: Option<String>,
1767 pub remote_tts_default: Option<String>,
1768}
1769
1770#[derive(Debug, Clone, Serialize)]
1771pub struct ModelDefaultHealth {
1772 pub capability: ModelCapability,
1773 pub configured_model: String,
1774 pub available: bool,
1775 pub is_local: bool,
1776 pub provider: Option<String>,
1777}
1778
1779#[derive(Debug, Clone, Serialize)]
1780pub struct ModelProviderHealth {
1781 pub provider: String,
1782 pub configured: bool,
1783 pub local_models: usize,
1784 pub remote_models: usize,
1785 pub available_models: usize,
1786 pub capabilities: Vec<ModelCapability>,
1787}
1788
1789#[derive(Debug, Clone, Serialize)]
1790pub struct ModelCapabilityHealth {
1791 pub capability: ModelCapability,
1792 pub total_models: usize,
1793 pub available_models: usize,
1794 pub local_available_models: usize,
1795 pub remote_available_models: usize,
1796}
1797
1798#[derive(Debug, Clone, Serialize)]
1799pub struct RoutingScenarioHealth {
1800 pub name: String,
1801 pub workload: RoutingWorkload,
1802 pub task_family: String,
1803 pub has_tools: bool,
1804 pub has_vision: bool,
1805 pub prefer_local: bool,
1806 pub quality_first_cold_start: bool,
1807 pub bootstrap_min_task_observations: u64,
1808 pub bootstrap_quality_floor: f64,
1809 pub model_id: String,
1810 pub model_name: String,
1811 pub reason: String,
1812 pub strategy: RoutingStrategy,
1813}
1814
1815#[derive(Debug, Clone, Serialize)]
1816pub struct ModelBenchmarkPriorHealth {
1817 pub model_id: String,
1818 pub model_name: Option<String>,
1819 pub overall_score: f64,
1820 pub overall_latency_ms: Option<f64>,
1821 pub task_scores: std::collections::HashMap<String, f64>,
1822 pub task_latency_ms: std::collections::HashMap<String, f64>,
1823 pub source_path: PathBuf,
1824}
1825
1826#[derive(Debug, Clone, Serialize)]
1827pub struct ModelHealthReport {
1828 pub total_models: usize,
1829 pub available_models: usize,
1830 pub local_models: usize,
1831 pub remote_models: usize,
1832 pub defaults: Vec<ModelDefaultHealth>,
1833 pub providers: Vec<ModelProviderHealth>,
1834 pub capabilities: Vec<ModelCapabilityHealth>,
1835 pub routing_prefer_local: bool,
1836 pub routing_quality_first_cold_start: bool,
1837 pub routing_min_observations: u64,
1838 pub routing_bootstrap_min_task_observations: u64,
1839 pub routing_bootstrap_quality_floor: f64,
1840 pub routing_quality_weight: f64,
1841 pub routing_latency_weight: f64,
1842 pub routing_cost_weight: f64,
1843 pub routing_scenarios: Vec<RoutingScenarioHealth>,
1844 pub benchmark_priors: Vec<ModelBenchmarkPriorHealth>,
1845 pub speech: SpeechHealthReport,
1846}
1847
1848#[derive(Debug, Clone, Serialize)]
1849pub struct SpeechInstallReport {
1850 pub name: String,
1851 pub hf_repo: String,
1852 pub snapshot_path: PathBuf,
1853 pub files_downloaded: usize,
1854}
1855
1856#[derive(Debug, Clone, Serialize)]
1857pub struct SpeechSmokePathReport {
1858 pub path: String,
1859 pub tts_model: String,
1860 pub stt_model: String,
1861 pub audio_path: PathBuf,
1862 pub transcript: String,
1863}
1864
1865#[derive(Debug, Clone, Serialize, Default)]
1866pub struct SpeechSmokeReport {
1867 pub local: Option<SpeechSmokePathReport>,
1868 pub remote: Option<SpeechSmokePathReport>,
1869 pub skipped: Vec<String>,
1870}
1871
1872#[derive(Debug, Clone, Serialize, Default)]
1873pub struct SpeechPolicy {
1874 pub prefer_local: bool,
1875 pub allow_remote_fallback: bool,
1876 pub preferred_local_stt: Option<String>,
1877 pub preferred_local_tts: Option<String>,
1878 pub preferred_remote_stt: Option<String>,
1879 pub preferred_remote_tts: Option<String>,
1880}
1881
1882fn render_for_local_backend(mut req: GenerateRequest) -> GenerateRequest {
1891 let has_msgs = req.messages.as_ref().is_some_and(|m| !m.is_empty());
1892 let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
1893 if has_msgs || has_tools {
1894 req.prompt = tasks::generate::render_chat_prompt(&req);
1895 req.messages = None;
1896 req.tools = None;
1897 }
1898 req
1899}
1900
1901pub struct InferenceEngine {
1906 pub config: InferenceConfig,
1907 pub unified_registry: UnifiedRegistry,
1909 pub adaptive_router: AdaptiveRouter,
1911 pub outcome_tracker: Arc<RwLock<OutcomeTracker>>,
1913 last_outcome_flush: Arc<std::sync::Mutex<Option<Instant>>>,
1919 ledger_io_lock: Arc<tokio::sync::Mutex<()>>,
1924 spend_limits: Arc<std::sync::RwLock<Option<SpendLimits>>>,
1930 lane_defaults_cache: Arc<std::sync::RwLock<crate::lane_defaults::LaneDefaults>>,
1935 concierge_action_lock: Arc<tokio::sync::Mutex<()>>,
1941 concierge_action_seq: Arc<std::sync::atomic::AtomicU64>,
1945 remote_backend: RemoteBackend,
1947 model_management: model_management::ModelManagementStore,
1950 local_admission: Arc<resource_policy::LocalAdmissionCoordinator>,
1952 _runtime_scope: Arc<ScopedInferenceRuntime>,
1957 resource_policy_generation: Arc<std::sync::atomic::AtomicU64>,
1958 resource_policy_evidence: Arc<std::sync::RwLock<resource_policy::ResourcePolicyLoadEvidence>>,
1962 model_budget: Arc<backend_cache::SharedModelBudget>,
1965 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1971 mlx_backends: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
1972 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1979 local_backends:
1980 Arc<backend_cache::BackendCache<Box<dyn backend::local::LocalInferenceBackend>>>,
1981 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1986 flux_cache: Arc<backend_cache::BackendCache<backend::mlx_flux::FluxBackend>>,
1987 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1989 ltx_cache: Arc<backend_cache::BackendCache<backend::mlx_ltx::LtxBackend>>,
1990 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1993 kokoro_cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
1994 pub registry: models::ModelRegistry,
1996 pub router: ModelRouter,
1997 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1998 backend: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
1999 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2000 embedding_backend: Arc<RwLock<Option<EmbeddingBackend>>>,
2001 speech_runtime: Arc<Mutex<Option<SpeechRuntime>>>,
2002 speech_policy: SpeechPolicy,
2003 vllm_pool: Arc<vllm_pool::VllmServerPool>,
2007}
2008
2009enum SpeechCandidateAdmission {
2010 Proceed(Option<resource_policy::LocalLoadReservation>),
2011 SkipBlocked(InferenceError),
2012 FailBlocked(InferenceError),
2013}
2014
2015struct ScopedInferenceRuntime {
2016 model_budget: Arc<backend_cache::SharedModelBudget>,
2017 resource_policy_generation: Arc<std::sync::atomic::AtomicU64>,
2018 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2019 mlx_backends: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
2020 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2021 local_backends:
2022 Arc<backend_cache::BackendCache<Box<dyn backend::local::LocalInferenceBackend>>>,
2023 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2024 flux_cache: Arc<backend_cache::BackendCache<backend::mlx_flux::FluxBackend>>,
2025 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2026 ltx_cache: Arc<backend_cache::BackendCache<backend::mlx_ltx::LtxBackend>>,
2027 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2028 kokoro_cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2029 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2030 backend: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
2031 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2032 embedding_backend: Arc<RwLock<Option<EmbeddingBackend>>>,
2033 speech_runtime: Arc<Mutex<Option<SpeechRuntime>>>,
2034 vllm_pool: Arc<vllm_pool::VllmServerPool>,
2035 #[cfg(test)]
2036 load_probe: Arc<backend_cache::BackendCache<()>>,
2037}
2038
2039fn scoped_inference_runtime_registry() -> &'static std::sync::Mutex<
2040 std::collections::HashMap<PathBuf, std::sync::Weak<ScopedInferenceRuntime>>,
2041> {
2042 static REGISTRY: std::sync::OnceLock<
2043 std::sync::Mutex<
2044 std::collections::HashMap<PathBuf, std::sync::Weak<ScopedInferenceRuntime>>,
2045 >,
2046 > = std::sync::OnceLock::new();
2047 REGISTRY.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
2048}
2049
2050fn configured_cache_budget_mb(default_mb: u64) -> u64 {
2051 std::env::var("CAR_INFERENCE_MODEL_CACHE_MB")
2052 .ok()
2053 .and_then(|value| value.parse::<u64>().ok())
2054 .unwrap_or(default_mb)
2055}
2056
2057fn scoped_inference_runtime(
2058 state_root: &Path,
2059 configured_ceiling_mb: u64,
2060 admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2061) -> Arc<ScopedInferenceRuntime> {
2062 let state_root = resource_policy::normalized_state_root_key(state_root);
2063 let mut registry = scoped_inference_runtime_registry()
2064 .lock()
2065 .unwrap_or_else(std::sync::PoisonError::into_inner);
2066 registry.retain(|_, runtime| runtime.strong_count() > 0);
2067 if let Some(runtime) = registry.get(&state_root).and_then(std::sync::Weak::upgrade) {
2068 runtime.model_budget.set_budget_bytes(
2069 configured_cache_budget_mb(configured_ceiling_mb).saturating_mul(1024 * 1024),
2070 );
2071 return runtime;
2072 }
2073
2074 let model_budget = backend_cache::SharedModelBudget::from_env_or(configured_ceiling_mb);
2075 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2076 let cache_idle = backend_cache::idle_ttl_from_env();
2077 let runtime = Arc::new(ScopedInferenceRuntime {
2078 model_budget: model_budget.clone(),
2079 resource_policy_generation: Arc::new(std::sync::atomic::AtomicU64::new(1)),
2080 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2081 mlx_backends: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2082 model_budget.clone(),
2083 cache_idle,
2084 Some(admission.clone()),
2085 )),
2086 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2087 local_backends: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2088 model_budget.clone(),
2089 cache_idle,
2090 Some(admission.clone()),
2091 )),
2092 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2093 flux_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2094 model_budget.clone(),
2095 cache_idle,
2096 Some(admission.clone()),
2097 )),
2098 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2099 ltx_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2100 model_budget.clone(),
2101 cache_idle,
2102 Some(admission.clone()),
2103 )),
2104 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2105 kokoro_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2106 model_budget,
2107 cache_idle,
2108 Some(admission.clone()),
2109 )),
2110 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2111 backend: Arc::new(RwLock::new(std::collections::HashMap::new())),
2112 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2113 embedding_backend: Arc::new(RwLock::new(None)),
2114 speech_runtime: Arc::new(Mutex::new(None)),
2115 vllm_pool: Arc::new(vllm_pool::VllmServerPool::with_admission(
2116 std::time::Duration::from_secs(
2117 std::env::var("CAR_VLLM_IDLE_SECS")
2118 .ok()
2119 .and_then(|value| value.parse().ok())
2120 .unwrap_or(300),
2121 ),
2122 admission,
2123 )),
2124 #[cfg(test)]
2125 load_probe: Arc::new(backend_cache::BackendCache::new(1024)),
2126 });
2127 registry.insert(state_root, Arc::downgrade(&runtime));
2128 runtime
2129}
2130
2131#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2135pub struct ScopedKokoroBackendCache {
2136 cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2137 _runtime: Arc<ScopedInferenceRuntime>,
2138 admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2139}
2140
2141#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2142impl ScopedKokoroBackendCache {
2143 pub fn cache(&self) -> &Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>> {
2144 &self.cache
2145 }
2146
2147 pub fn admission(&self) -> &Arc<resource_policy::LocalAdmissionCoordinator> {
2148 &self.admission
2149 }
2150}
2151
2152#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2153pub fn scoped_kokoro_backend_cache(state_root: &Path) -> ScopedKokoroBackendCache {
2154 let state_root = resource_policy::normalized_state_root_key(state_root);
2155 let policy = resource_policy::FileResourcePolicyRepository::new(state_root.clone())
2156 .load()
2157 .unwrap_or_else(|_| resource_policy::ResourcePolicy::everyday());
2158 let hardware = HardwareInfo::detect();
2159 let admission = resource_policy::scoped_local_admission(&state_root, policy, hardware.clone());
2160 let ceiling_mb = admission
2161 .policy()
2162 .effective_budget(hardware.total_ram_mb)
2163 .configured_model_ceiling_mb;
2164 let runtime = scoped_inference_runtime(&state_root, ceiling_mb, admission.clone());
2165 ScopedKokoroBackendCache {
2166 cache: runtime.kokoro_cache.clone(),
2167 _runtime: runtime,
2168 admission,
2169 }
2170}
2171
2172impl InferenceEngine {
2173 fn requires_local_admission(schema: &ModelSchema) -> bool {
2174 schema.is_car_managed_vllm_mlx() || Self::supports_worker_offload(schema)
2175 }
2176
2177 fn reserve_in_outer_dispatch(schema: &ModelSchema) -> bool {
2183 Self::requires_local_admission(schema) && !schema.is_car_managed_vllm_mlx()
2184 }
2185
2186 fn supports_worker_offload(schema: &ModelSchema) -> bool {
2187 matches!(
2188 schema.source,
2189 ModelSource::Local { .. } | ModelSource::Mlx { .. }
2190 )
2191 }
2192
2193 pub fn local_model_retention(&self, model_id: &str) -> backend_cache::BackendRetention {
2196 if self.local_admission.is_resident(model_id) {
2197 backend_cache::BackendRetention::Resident
2198 } else {
2199 backend_cache::BackendRetention::Transient
2200 }
2201 }
2202 pub fn local_model_preflight(
2204 &self,
2205 model_id: &str,
2206 context_tokens: usize,
2207 ) -> Result<resource_policy::LocalLoadPreflight, InferenceError> {
2208 let schema = self
2209 .unified_registry
2210 .get(model_id)
2211 .or_else(|| self.unified_registry.find_by_name(model_id))
2212 .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
2213 self.ensure_model_enabled(&schema.id)?;
2214 Ok(self.local_admission.preflight(schema, context_tokens))
2215 }
2216
2217 pub fn active_local_resource_policy(&self) -> resource_policy::ResourcePolicyLoadEvidence {
2224 let mut evidence = self
2225 .resource_policy_evidence
2226 .read()
2227 .unwrap_or_else(|poisoned| poisoned.into_inner())
2228 .clone();
2229 evidence.policy = self.local_admission.policy();
2230 evidence
2231 }
2232
2233 pub fn apply_local_resource_policy(&self, policy: resource_policy::ResourcePolicy) {
2237 let ceiling_mb = policy
2238 .effective_budget(HardwareInfo::detect().total_ram_mb)
2239 .configured_model_ceiling_mb;
2240 self.local_admission.set_policy(policy.clone());
2241 *self
2242 .resource_policy_evidence
2243 .write()
2244 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
2245 resource_policy::ResourcePolicyLoadEvidence {
2246 policy,
2247 source: resource_policy::ResourcePolicyLoadSource::Loaded,
2248 warning: None,
2249 };
2250 let cache_ceiling_mb = configured_cache_budget_mb(ceiling_mb);
2251 self.model_budget
2252 .set_budget_bytes(cache_ceiling_mb.saturating_mul(1024 * 1024));
2253 let generation = self
2254 .resource_policy_generation
2255 .fetch_add(1, std::sync::atomic::Ordering::AcqRel)
2256 .saturating_add(1);
2257 if let Some(offload) = crate::offload::current_local_offload() {
2258 offload.refresh_resource_policy(generation);
2259 }
2260 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2261 {
2262 self.mlx_backends.enforce_budget();
2263 self.local_backends.enforce_budget();
2264 self.flux_cache.enforce_budget();
2265 self.ltx_cache.enforce_budget();
2266 self.kokoro_cache.enforce_budget();
2267 }
2268 }
2269
2270 pub fn begin_local_model_maintenance(
2271 &self,
2272 model_id: &str,
2273 ) -> Result<resource_policy::LocalModelMaintenanceGuard, resource_policy::ModelMaintenanceError>
2274 {
2275 self.local_admission.begin_model_maintenance(model_id)
2276 }
2277
2278 pub async fn prepare_local_model_removal(
2279 &self,
2280 model_id: &str,
2281 ) -> Result<resource_policy::LocalModelMaintenanceGuard, resource_policy::ModelMaintenanceError>
2282 {
2283 let maintenance = self.local_admission.begin_model_maintenance(model_id)?;
2284 if let Some(offload) = crate::offload::current_local_offload() {
2285 if offload
2286 .resident_models()
2287 .await
2288 .iter()
2289 .any(|resident| resident == model_id)
2290 {
2291 let acknowledged = offload.release_model(model_id).await.map_err(|error| {
2292 resource_policy::ModelMaintenanceError::ReleaseFailed(error.to_string())
2293 })?;
2294 if !acknowledged
2295 || offload
2296 .resident_models()
2297 .await
2298 .iter()
2299 .any(|resident| resident == model_id)
2300 {
2301 return Err(
2302 resource_policy::ModelMaintenanceError::WorkerReleaseUnacknowledged(
2303 model_id.to_string(),
2304 ),
2305 );
2306 }
2307 }
2308 }
2309 if self
2310 .vllm_pool
2311 .release_model_if_present(model_id)
2312 .await
2313 .is_err()
2314 {
2315 return Err(
2316 resource_policy::ModelMaintenanceError::ProcessReleaseUnacknowledged(
2317 model_id.to_string(),
2318 ),
2319 );
2320 }
2321 if !self.evict_local_model_if_idle(model_id) {
2322 return Err(resource_policy::ModelMaintenanceError::CacheReleaseBlocked(
2323 model_id.to_string(),
2324 ));
2325 }
2326 let allocation_ids = self.local_admission.resident_allocation_ids(model_id);
2327 if !allocation_ids.is_empty() {
2328 return Err(resource_policy::ModelMaintenanceError::ResidualResidency {
2329 model_id: model_id.to_string(),
2330 allocation_ids,
2331 });
2332 }
2333 Ok(maintenance)
2334 }
2335
2336 pub fn evict_local_model_if_idle(&self, model_id: &str) -> bool {
2340 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2341 {
2342 let mut allocation_ids = self.local_admission.resident_allocation_ids(model_id);
2343 if !allocation_ids.iter().any(|id| id == model_id) {
2344 allocation_ids.push(model_id.to_string());
2345 }
2346 allocation_ids.into_iter().all(|allocation_id| {
2347 [
2348 self.mlx_backends.evict_if_idle(&allocation_id),
2349 self.local_backends.evict_if_idle(&allocation_id),
2350 self.flux_cache.evict_if_idle(&allocation_id),
2351 self.ltx_cache.evict_if_idle(&allocation_id),
2352 self.kokoro_cache.evict_if_idle(&allocation_id),
2353 ]
2354 .into_iter()
2355 .all(|evicted| evicted)
2356 })
2357 }
2358 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2359 {
2360 let _ = model_id;
2361 let backend_released = self.backend.try_write().is_ok_and(|mut backends| {
2362 if backends.remove(model_id).is_some() {
2363 self.local_admission.mark_evicted(model_id);
2364 }
2365 true
2366 });
2367 backend_released
2371 && self
2372 .embedding_backend
2373 .try_write()
2374 .is_ok_and(|slot| slot.is_none())
2375 }
2376 }
2377
2378 fn reserve_local_request(
2379 &self,
2380 schema: &ModelSchema,
2381 context_tokens: usize,
2382 ) -> Result<resource_policy::LocalLoadReservation, InferenceError> {
2383 self.ensure_model_enabled(&schema.id)?;
2384 let activity_lease = self.model_management.acquire_lease(&schema.id)?;
2385 let mut reservation = self
2386 .local_admission
2387 .reserve(schema, context_tokens)
2388 .map_err(InferenceError::from)?;
2389 reservation.attach_activity_lease(activity_lease);
2390 Ok(reservation)
2391 }
2392
2393 #[cfg(any(
2398 test,
2399 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))
2400 ))]
2401 fn reconcile_transient_local_allocation(
2402 reservation: &mut resource_policy::LocalLoadReservation,
2403 measured_weights_bytes: u64,
2404 ) -> Result<(), InferenceError> {
2405 reservation
2406 .reconcile_measured_weights(measured_weights_bytes)
2407 .map(|_| ())
2408 .map_err(InferenceError::from)
2409 }
2410
2411 fn prepare_worker_admission(
2412 &self,
2413 schema: &ModelSchema,
2414 reservation: &mut resource_policy::LocalLoadReservation,
2415 ) -> Result<crate::offload::LocalWorkerAdmission, InferenceError> {
2416 let installed = self.config.models_dir.join(&schema.name);
2417 let installed_weights_bytes = backend_cache::estimate_model_size(&installed);
2418 let measured_weights_bytes =
2425 installed_weights_bytes.max(reservation.reconciled_weights_bytes());
2426 reservation
2427 .reconcile_measured_weights(measured_weights_bytes)
2428 .map_err(InferenceError::from)?;
2429 Ok(crate::offload::LocalWorkerAdmission {
2430 policy: self.local_admission.policy(),
2431 policy_generation: self
2432 .resource_policy_generation
2433 .load(std::sync::atomic::Ordering::Acquire),
2434 state_root: resource_policy::normalized_state_root_key(&self.config.state_root),
2435 measured_weights_bytes,
2436 })
2437 }
2438
2439 async fn reconcile_worker_residency(
2440 offload: &dyn crate::offload::LocalGenerationOffload,
2441 expected_model_id: &str,
2442 residency: &crate::offload::LocalWorkerResidency,
2443 retention: backend_cache::BackendRetention,
2444 reservation: &mut resource_policy::LocalLoadReservation,
2445 ) -> Result<(), InferenceError> {
2446 if residency.model_id != expected_model_id {
2447 if retention == backend_cache::BackendRetention::Resident {
2448 let released = offload
2449 .release_model(&residency.model_id)
2450 .await
2451 .unwrap_or(false);
2452 if !released {
2453 let allocation_id = offload
2454 .resident_allocation_id(&residency.model_id)
2455 .unwrap_or_else(|| {
2456 resource_policy::worker_process_allocation_id(&residency.model_id)
2457 });
2458 reservation.publish_resident_weights_as(
2459 &allocation_id,
2460 residency.measured_weights_bytes,
2461 );
2462 }
2463 }
2464 return Err(InferenceError::InferenceFailed(format!(
2465 "local worker acknowledged model '{}' for requested '{}'",
2466 residency.model_id, expected_model_id
2467 )));
2468 }
2469 if retention == backend_cache::BackendRetention::Resident {
2470 let allocation_id = offload
2471 .resident_allocation_id(expected_model_id)
2472 .unwrap_or_else(|| {
2473 resource_policy::worker_process_allocation_id(expected_model_id)
2474 });
2475 reservation
2476 .publish_resident_weights_as(&allocation_id, residency.measured_weights_bytes);
2477 }
2478 Ok(())
2479 }
2480
2481 fn admit_speech_candidate(
2482 &self,
2483 schema: &ModelSchema,
2484 explicit: bool,
2485 ) -> SpeechCandidateAdmission {
2486 if !schema.is_local()
2487 || matches!(
2488 schema.source,
2489 ModelSource::WindowsSpeech {} | ModelSource::AppleFoundationModels { .. }
2490 )
2491 {
2492 return SpeechCandidateAdmission::Proceed(None);
2493 }
2494 match self.reserve_local_request(schema, 0) {
2495 Ok(reservation) => SpeechCandidateAdmission::Proceed(Some(reservation)),
2496 Err(error) if explicit => SpeechCandidateAdmission::FailBlocked(error),
2497 Err(error) => SpeechCandidateAdmission::SkipBlocked(error),
2498 }
2499 }
2500
2501 fn hold_local_reservation_for_stream(
2502 mut source: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
2503 reservation: resource_policy::LocalLoadReservation,
2504 ) -> tokio::sync::mpsc::Receiver<stream::StreamEvent> {
2505 let (tx, rx) = tokio::sync::mpsc::channel(64);
2506 tokio::spawn(async move {
2507 let _reservation = reservation;
2508 while let Some(event) = source.recv().await {
2509 if tx.send(event).await.is_err() {
2510 break;
2511 }
2512 }
2513 });
2514 rx
2515 }
2516
2517 fn hold_optional_reservation_for_stream(
2518 source: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
2519 reservation: Option<resource_policy::LocalLoadReservation>,
2520 ) -> tokio::sync::mpsc::Receiver<stream::StreamEvent> {
2521 match reservation {
2522 Some(reservation) => Self::hold_local_reservation_for_stream(source, reservation),
2523 None => source,
2524 }
2525 }
2526
2527 pub fn set_spend_limits(&self, limits: Option<SpendLimits>) {
2533 *self.spend_limits.write().unwrap() = limits;
2534 }
2535
2536 fn preferred_model_for_capability(&self, capability: ModelCapability) -> Option<&str> {
2537 match capability {
2538 ModelCapability::Generate => self.config.preferred_generation_model.as_deref(),
2539 ModelCapability::Embed => self.config.preferred_embedding_model.as_deref(),
2540 ModelCapability::Classify => self.config.preferred_classification_model.as_deref(),
2541 _ => None,
2542 }
2543 }
2544
2545 fn request_has_tools(req: &GenerateRequest) -> bool {
2551 req.tools.as_ref().is_some_and(|t| !t.is_empty())
2552 }
2553
2554 fn request_needs_vision(req: &GenerateRequest) -> bool {
2555 req.images.as_ref().is_some_and(|images| !images.is_empty())
2556 || req.messages.as_ref().is_some_and(|messages| {
2557 messages
2558 .iter()
2559 .any(|msg| matches!(msg, Message::UserMultimodal { .. }))
2560 })
2561 }
2562
2563 #[allow(dead_code)] fn request_has_video(req: &GenerateRequest) -> bool {
2569 let images_have_video = req
2570 .images
2571 .as_ref()
2572 .is_some_and(|blocks| blocks.iter().any(ContentBlock::is_video));
2573 let messages_have_video = req.messages.as_ref().is_some_and(|messages| {
2574 messages.iter().any(|msg| match msg {
2575 Message::UserMultimodal { content } => content.iter().any(ContentBlock::is_video),
2576 _ => false,
2577 })
2578 });
2579 images_have_video || messages_have_video
2580 }
2581
2582 #[allow(dead_code)] fn request_has_audio(req: &GenerateRequest) -> bool {
2587 let images_have_audio = req
2588 .images
2589 .as_ref()
2590 .is_some_and(|blocks| blocks.iter().any(ContentBlock::is_audio));
2591 let messages_have_audio = req.messages.as_ref().is_some_and(|messages| {
2592 messages.iter().any(|msg| match msg {
2593 Message::UserMultimodal { content } => content.iter().any(ContentBlock::is_audio),
2594 _ => false,
2595 })
2596 });
2597 images_have_audio || messages_have_audio
2598 }
2599
2600 pub fn new(config: InferenceConfig) -> Self {
2601 let registry = models::ModelRegistry::new(config.models_dir.clone());
2602 let hw = HardwareInfo::detect();
2603 let policy_evidence =
2604 resource_policy::FileResourcePolicyRepository::new(config.state_root.clone())
2605 .load_with_evidence()
2606 .unwrap_or_else(|error| {
2607 tracing::warn!(%error, "failed to read local model resource policy; using Everyday");
2608 resource_policy::ResourcePolicyLoadEvidence {
2609 policy: resource_policy::ResourcePolicy::everyday(),
2610 source: resource_policy::ResourcePolicyLoadSource::CorruptDefault,
2611 warning: Some(format!(
2612 "The local-model resource policy could not be read ({error}); CAR used Everyday."
2613 )),
2614 }
2615 });
2616 let policy = policy_evidence.policy.clone();
2617 let effective_budget = policy.effective_budget(hw.total_ram_mb);
2618 let local_admission =
2619 resource_policy::scoped_local_admission(&config.state_root, policy, hw.clone());
2620 let runtime_scope = scoped_inference_runtime(
2621 &config.state_root,
2622 effective_budget.configured_model_ceiling_mb,
2623 local_admission.clone(),
2624 );
2625 let router = ModelRouter::new(hw.clone());
2626 let unified_registry = UnifiedRegistry::new_with_state_root(
2627 config.state_root.clone(),
2628 config.models_dir.clone(),
2629 );
2630 let adaptive_router = AdaptiveRouter::with_default_config(hw);
2631 let mut tracker = OutcomeTracker::new();
2632 let profiles_path = config.state_models_dir().join("outcome_profiles.json");
2634 if let Ok(n) = tracker.load_from_file(&profiles_path) {
2635 if n > 0 {
2636 tracing::info!(loaded = n, "loaded persisted model profiles");
2637 }
2638 }
2639 let mut benchmark_models_loaded = 0usize;
2640 for path in benchmark_priors_paths(&config.state_models_dir()) {
2641 match routing_ext::load_benchmark_priors(&path) {
2642 Ok(priors) if !priors.is_empty() => {
2643 benchmark_models_loaded += priors.len();
2644 routing_ext::apply_benchmark_priors(&mut tracker, &priors);
2645 tracing::info!(
2646 path = %path.display(),
2647 loaded = priors.len(),
2648 "loaded benchmark quality priors"
2649 );
2650 }
2651 Ok(_) => {}
2652 Err(error) => {
2653 tracing::warn!(path = %path.display(), %error, "failed to load benchmark priors");
2654 }
2655 }
2656 }
2657 if benchmark_models_loaded > 0 {
2658 tracing::info!(
2659 loaded = benchmark_models_loaded,
2660 "applied benchmark priors to cold-start routing"
2661 );
2662 }
2663 let outcome_tracker = Arc::new(RwLock::new(tracker));
2664
2665 let remote_backend = RemoteBackend::new();
2666 let model_management = model_management::ModelManagementStore::new(
2667 config.state_root.clone(),
2668 config.models_dir.clone(),
2669 );
2670
2671 Self {
2672 config,
2673 unified_registry,
2674 adaptive_router,
2675 outcome_tracker,
2676 last_outcome_flush: Arc::new(std::sync::Mutex::new(None)),
2677 ledger_io_lock: Arc::new(tokio::sync::Mutex::new(())),
2678 spend_limits: Arc::new(std::sync::RwLock::new(None)),
2679 lane_defaults_cache: Arc::new(std::sync::RwLock::new(crate::lane_defaults::load_from(
2680 &crate::lane_defaults::default_path(),
2681 ))),
2682 concierge_action_lock: Arc::new(tokio::sync::Mutex::new(())),
2683 concierge_action_seq: Arc::new(std::sync::atomic::AtomicU64::new(
2684 crate::action_ledger::read_actions(&crate::action_ledger::default_path(), 0)
2685 .iter()
2686 .map(|a| a.seq)
2687 .max()
2688 .map(|m| m + 1)
2689 .unwrap_or(1),
2690 )),
2691 remote_backend,
2692 model_management,
2693 local_admission: local_admission.clone(),
2694 _runtime_scope: runtime_scope.clone(),
2695 resource_policy_generation: runtime_scope.resource_policy_generation.clone(),
2696 resource_policy_evidence: Arc::new(std::sync::RwLock::new(policy_evidence)),
2697 model_budget: runtime_scope.model_budget.clone(),
2698 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2699 mlx_backends: runtime_scope.mlx_backends.clone(),
2700 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2701 local_backends: runtime_scope.local_backends.clone(),
2702 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2703 flux_cache: runtime_scope.flux_cache.clone(),
2704 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2705 ltx_cache: runtime_scope.ltx_cache.clone(),
2706 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2707 kokoro_cache: runtime_scope.kokoro_cache.clone(),
2708 registry,
2709 router,
2710 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2711 backend: runtime_scope.backend.clone(),
2712 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2713 embedding_backend: runtime_scope.embedding_backend.clone(),
2714 speech_runtime: runtime_scope.speech_runtime.clone(),
2715 speech_policy: SpeechPolicy {
2716 prefer_local: cfg!(all(
2717 target_os = "macos",
2718 target_arch = "aarch64",
2719 not(car_skip_mlx)
2720 )),
2721 allow_remote_fallback: true,
2722 preferred_local_stt: None,
2723 preferred_local_tts: None,
2724 preferred_remote_stt: None,
2725 preferred_remote_tts: None,
2726 },
2727 vllm_pool: runtime_scope.vllm_pool.clone(),
2728 }
2729 }
2730
2731 pub async fn init_key_pool(&self) {
2734 for schema in self.unified_registry.list() {
2736 if schema.is_remote() {
2737 self.remote_backend.register_model_keys(schema).await;
2738 }
2739 }
2740
2741 let stats_path = self.config.state_models_dir().join("key_pool_stats.json");
2743 if let Ok(n) = self.remote_backend.key_pool.load_stats(&stats_path).await {
2744 if n > 0 {
2745 tracing::info!(loaded = n, "loaded persisted key pool stats");
2746 }
2747 }
2748
2749 let total = self.remote_backend.key_pool.total_keys().await;
2750 if total > 0 {
2751 tracing::info!(keys = total, "key pool initialized");
2752 }
2753 }
2754
2755 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2758 async fn ensure_backend(
2759 &self,
2760 schema: &ModelSchema,
2761 reservation: &mut resource_policy::LocalLoadReservation,
2762 ) -> Result<(), InferenceError> {
2763 let read = self.backend.read().await;
2764 if read.contains_key(&schema.id) {
2765 return Ok(());
2766 }
2767 drop(read);
2768
2769 let mut write = self.backend.write().await;
2770 if write.contains_key(&schema.id) {
2771 return Ok(());
2772 }
2773
2774 let model_path = self.registry.ensure_model(&schema.name).await?;
2775 let mut measured = backend_cache::estimate_model_size(&model_path);
2776 reservation
2777 .reconcile_measured_weights(measured)
2778 .map_err(InferenceError::from)?;
2779 let device = self.config.device.unwrap_or_else(Device::auto);
2780 let backend = match CandleBackend::load(&model_path, device) {
2782 Ok(b) => b,
2783 Err(load_err) => {
2784 if crate::download::purge_corrupt_cache_files(&model_path) == 0 {
2788 return Err(load_err);
2789 }
2790 tracing::warn!(
2791 model = %schema.id,
2792 error = %load_err,
2793 "candle backend load failed; purged corrupt cache files and re-pulling once"
2794 );
2795 let model_path = self.registry.ensure_model(&schema.name).await?;
2796 measured = backend_cache::estimate_model_size(&model_path);
2797 reservation
2798 .reconcile_measured_weights(measured)
2799 .map_err(InferenceError::from)?;
2800 CandleBackend::load(&model_path, device)?
2801 }
2802 };
2803 write.insert(schema.id.clone(), backend);
2804 reservation.publish_resident_weights(measured);
2805 Ok(())
2806 }
2807
2808 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2811 async fn ensure_embedding_backend(
2812 &self,
2813 reservation: &mut resource_policy::LocalLoadReservation,
2814 ) -> Result<(), InferenceError> {
2815 let read = self.embedding_backend.read().await;
2816 if read.is_some() {
2817 return Ok(());
2818 }
2819 drop(read);
2820
2821 let mut write = self.embedding_backend.write().await;
2822 if write.is_some() {
2823 return Ok(());
2824 }
2825
2826 let embedding_model = self
2827 .preferred_model_for_capability(ModelCapability::Embed)
2828 .unwrap_or(&self.config.embedding_model);
2829 let model_path = self.registry.ensure_model(embedding_model).await?;
2830 let mut measured = backend_cache::estimate_model_size(&model_path);
2831 reservation
2832 .reconcile_measured_weights(measured)
2833 .map_err(InferenceError::from)?;
2834 let device = self.config.device.unwrap_or_else(Device::auto);
2835 let backend = match EmbeddingBackend::load(&model_path, device) {
2837 Ok(b) => b,
2838 Err(load_err) => {
2839 if crate::download::purge_corrupt_cache_files(&model_path) == 0 {
2840 return Err(load_err);
2841 }
2842 tracing::warn!(
2843 model = embedding_model,
2844 error = %load_err,
2845 "embedding backend load failed; purged corrupt cache files and re-pulling once"
2846 );
2847 let model_path = self.registry.ensure_model(embedding_model).await?;
2848 measured = backend_cache::estimate_model_size(&model_path);
2849 reservation
2850 .reconcile_measured_weights(measured)
2851 .map_err(InferenceError::from)?;
2852 EmbeddingBackend::load(&model_path, device)?
2853 }
2854 };
2855 *write = Some(backend);
2856 reservation.publish_resident_weights(measured);
2857 Ok(())
2858 }
2859
2860 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2863 async fn ensure_mlx_embedding_backend(&self) -> Result<String, InferenceError> {
2864 let embedding_model_name = self
2865 .preferred_model_for_capability(ModelCapability::Embed)
2866 .unwrap_or(&self.config.embedding_model)
2867 .to_string();
2868 let schema = self
2869 .unified_registry
2870 .get(&embedding_model_name)
2871 .or_else(|| self.unified_registry.find_by_name(&embedding_model_name))
2872 .ok_or_else(|| InferenceError::ModelNotFound(embedding_model_name.clone()))?
2873 .clone();
2874 Ok(schema.id)
2875 }
2876
2877 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2893 async fn load_backend_healing<T, F, RF, R>(
2894 schema_id: &str,
2895 model_dir: std::path::PathBuf,
2896 cache: &backend_cache::BackendCache<T>,
2897 size: u64,
2898 reservation: &mut resource_policy::LocalLoadReservation,
2899 loader: F,
2900 repull: RF,
2901 ) -> Result<
2902 (
2903 backend_cache::CachedBackend<T>,
2904 backend_cache::BackendRetention,
2905 ),
2906 InferenceError,
2907 >
2908 where
2909 T: Send + 'static,
2910 F: Fn(&Path) -> Result<T, InferenceError>,
2911 RF: FnOnce() -> R,
2912 R: std::future::Future<Output = Result<std::path::PathBuf, InferenceError>>,
2913 {
2914 match cache.get_or_load_admitted(schema_id, size, reservation, || loader(&model_dir)) {
2915 Ok(admitted) => Ok(admitted),
2916 Err(load_err) => {
2917 let purged = crate::download::purge_corrupt_cache_files(&model_dir);
2925 if purged == 0 {
2926 return Err(load_err);
2928 }
2929 tracing::warn!(
2930 model = schema_id,
2931 purged,
2932 error = %load_err,
2933 "backend load failed; purged corrupt cache files and re-pulling once"
2934 );
2935 let fresh_dir = repull().await?;
2936 let fresh_size = backend_cache::estimate_model_size(&fresh_dir);
2937 cache
2938 .get_or_load_admitted(schema_id, fresh_size, reservation, || loader(&fresh_dir))
2939 }
2940 }
2941 }
2942
2943 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2947 async fn ensure_mlx_backend(
2948 &self,
2949 schema: &ModelSchema,
2950 reservation: &mut resource_policy::LocalLoadReservation,
2951 ) -> Result<
2952 (
2953 backend_cache::CachedBackend<backend::MlxBackend>,
2954 backend_cache::BackendRetention,
2955 ),
2956 InferenceError,
2957 > {
2958 if !Self::supports_native_mlx(schema) {
2959 return Err(InferenceError::InferenceFailed(format!(
2960 "native MLX backend does not support {} ({}) yet; use vLLM-MLX or add a family-specific MLX backend",
2961 schema.name, schema.family
2962 )));
2963 }
2964
2965 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
2966 let size = backend_cache::estimate_model_size(&model_dir);
2967 if !reservation.authorizes_model(&schema.id) {
2968 return Err(InferenceError::InferenceFailed(format!(
2969 "local admission reservation does not authorize {}",
2970 schema.id
2971 )));
2972 }
2973 let loader = |dir: &Path| {
2977 let dir = dir.to_path_buf();
2978 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2979 backend::MlxBackend::load(&dir)
2981 }))
2982 .map_err(|e| {
2983 InferenceError::InferenceFailed(format!(
2984 "MLX backend loading panicked (possible Metal/accelerate exception): {:?}",
2985 e
2986 ))
2987 })?
2988 };
2989 Self::load_backend_healing(
2990 &schema.id,
2991 model_dir,
2992 &self.mlx_backends,
2993 size,
2994 reservation,
2995 loader,
2996 || self.unified_registry.redownload_local(&schema.id),
2997 )
2998 .await
2999 }
3000
3001 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3006 async fn ensure_local_backend(
3007 &self,
3008 schema: &ModelSchema,
3009 reservation: &mut resource_policy::LocalLoadReservation,
3010 ) -> Result<
3011 (
3012 backend_cache::CachedBackend<Box<dyn backend::local::LocalInferenceBackend>>,
3013 backend_cache::BackendRetention,
3014 ),
3015 InferenceError,
3016 > {
3017 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3018 let size = backend_cache::estimate_model_size(&model_dir);
3019 if !reservation.authorizes_model(&schema.id) {
3020 return Err(InferenceError::InferenceFailed(format!(
3021 "local admission reservation does not authorize {}",
3022 schema.id
3023 )));
3024 }
3025 let loader = |dir: &Path| {
3026 let dir = dir.to_path_buf();
3027 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3028 backend::local::local_backend_for(&dir)
3030 }))
3031 .map_err(|e| {
3032 InferenceError::InferenceFailed(format!(
3033 "local backend loading panicked (possible Metal/accelerate exception): {:?}",
3034 e
3035 ))
3036 })?
3037 };
3038 Self::load_backend_healing(
3039 &schema.id,
3040 model_dir,
3041 &self.local_backends,
3042 size,
3043 reservation,
3044 loader,
3045 || self.unified_registry.redownload_local(&schema.id),
3046 )
3047 .await
3048 }
3049
3050 pub async fn reset_local_kv_cache(&self, model_id: &str) {
3061 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3065 {
3066 let Some(schema) = self.unified_registry.get(model_id).cloned() else {
3067 return;
3068 };
3069 if !matches!(
3073 schema.source,
3074 ModelSource::Mlx { .. } | ModelSource::Local { .. }
3075 ) {
3076 return;
3077 }
3078 if !self.local_backends.contains(&schema.id) {
3081 return;
3082 }
3083 let Ok(mut reservation) = self.reserve_local_request(&schema, 0) else {
3084 return;
3085 };
3086 if let Ok((handle, _retention)) =
3087 self.ensure_local_backend(&schema, &mut reservation).await
3088 {
3089 if let Ok(mut guard) = handle.lock() {
3090 guard.clear_kv_cache();
3091 }
3092 }
3093 }
3094 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3095 let _ = model_id;
3096 }
3097
3098 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3105 pub async fn warm_up<S: AsRef<str>>(
3106 &self,
3107 schema_ids: &[S],
3108 ) -> Vec<Result<(), InferenceError>> {
3109 let mut results = Vec::with_capacity(schema_ids.len());
3110 for id in schema_ids {
3111 let id = id.as_ref();
3112 let outcome: Result<(), InferenceError> = async {
3113 let schema = self.unified_registry.get(id).cloned().ok_or_else(|| {
3114 InferenceError::InferenceFailed(format!("warm_up: unknown schema id {id}"))
3115 })?;
3116 let mut reservation = self.reserve_local_request(&schema, 0)?;
3118 match schema.capabilities.first().copied() {
3119 Some(ModelCapability::ImageGeneration) => {
3120 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3121 let size = backend_cache::estimate_model_size(&model_dir);
3122 let _ = self.flux_cache.get_or_load_admitted(
3123 &schema.id,
3124 size,
3125 &mut reservation,
3126 || backend::mlx_flux::FluxBackend::load(&model_dir),
3127 )?;
3128 }
3129 Some(ModelCapability::VideoGeneration) => {
3130 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3131 let size = backend_cache::estimate_model_size(&model_dir);
3132 let _ = self.ltx_cache.get_or_load_admitted(
3133 &schema.id,
3134 size,
3135 &mut reservation,
3136 || {
3137 backend::mlx_ltx::LtxBackend::load(&model_dir)
3139 },
3140 )?;
3141 }
3142 Some(ModelCapability::TextToSpeech) => {
3143 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3144 let size = backend_cache::estimate_model_size(&model_dir);
3145 let cache_key = reservation.model_id().to_string();
3146 let _ = self.kokoro_cache.get_or_load_admitted(
3147 &cache_key,
3148 size,
3149 &mut reservation,
3150 || backend::mlx_kokoro::KokoroBackend::load(&model_dir),
3151 )?;
3152 }
3153 _ => {
3154 let _ = self.ensure_mlx_backend(&schema, &mut reservation).await?;
3155 }
3156 }
3157 Ok(())
3158 }
3159 .await;
3160 results.push(outcome);
3161 }
3162 results
3163 }
3164
3165 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3167 pub async fn warm_up<S: AsRef<str>>(
3168 &self,
3169 _schema_ids: &[S],
3170 ) -> Vec<Result<(), InferenceError>> {
3171 Vec::new()
3172 }
3173
3174 async fn vllm_live_schema(
3180 &self,
3181 schema: ModelSchema,
3182 reservation: Option<resource_policy::LocalLoadReservation>,
3183 context_tokens: usize,
3184 ) -> Result<(ModelSchema, Option<resource_policy::LocalLoadReservation>), InferenceError> {
3185 match &schema.source {
3186 ModelSource::ManagedVllmMlx { .. } => {}
3187 ModelSource::VllmMlx { .. } => return Ok((schema, None)),
3188 _ => return Ok((schema, None)),
3189 }
3190 let _dispatch = self.vllm_pool.acquire_dispatch(&schema.id).await;
3195 self.vllm_pool
3196 .wait_for_teardown(&schema.id, std::time::Duration::from_secs(5))
3197 .await
3198 .map_err(InferenceError::InferenceFailed)?;
3199 self.vllm_pool
3204 .reap_dead(&schema.id)
3205 .await
3206 .map_err(InferenceError::InferenceFailed)?;
3207 let mut reservation = match reservation {
3208 Some(reservation) if reservation.model_id() == schema.id => reservation,
3209 _ => self.reserve_local_request(&schema, context_tokens)?,
3210 };
3211 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3212 let model_name = model_dir.display().to_string();
3213 let measured = backend_cache::estimate_model_size(&model_dir)
3214 .max(reservation.reconciled_weights_bytes());
3215 reservation
3216 .reconcile_measured_weights(measured)
3217 .map_err(InferenceError::from)?;
3218 let endpoint = self
3220 .vllm_pool
3221 .ensure(&schema.id, &model_name, &reservation, &schema.family)
3224 .await
3225 .map_err(InferenceError::InferenceFailed)?;
3226 let allocation_id = resource_policy::vllm_process_allocation_id(&schema.id);
3227 reservation.publish_resident_weights_as(&allocation_id, measured);
3228 let mut schema = schema;
3229 schema.source = ModelSource::VllmMlx {
3230 endpoint,
3231 model_name,
3232 };
3233 Ok((schema, Some(reservation)))
3234 }
3235
3236 pub async fn evict_idle_vllm_servers(&self) -> usize {
3239 self.vllm_pool.evict_idle().await
3240 }
3241
3242 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3253 pub fn evict_idle_backends(&self) -> (usize, u64) {
3254 let mut entries = 0usize;
3255 let mut bytes = 0u64;
3256 for (n, b) in [
3257 self.mlx_backends.evict_idle(),
3258 self.local_backends.evict_idle(),
3259 self.flux_cache.evict_idle(),
3260 self.ltx_cache.evict_idle(),
3261 self.kokoro_cache.evict_idle(),
3262 ] {
3263 entries += n;
3264 bytes = bytes.saturating_add(b);
3265 }
3266 (entries, bytes)
3267 }
3268
3269 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3271 pub fn evict_idle_backends(&self) -> (usize, u64) {
3272 (0, 0)
3273 }
3274
3275 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3276 fn supports_native_mlx(schema: &ModelSchema) -> bool {
3277 matches!(schema.family.as_str(), "qwen3" | "qwen2.5-vl" | "qwen2-vl")
3278 }
3279
3280 fn catalog_registry_snapshot(&self) -> UnifiedRegistry {
3281 let mut registry = self.unified_registry.clone();
3282 registry.refresh_availability();
3286 self.filter_disabled_local_models(&mut registry);
3287 registry
3288 }
3289
3290 fn filter_disabled_local_models(&self, registry: &mut UnifiedRegistry) {
3291 let disabled = registry
3292 .all()
3293 .filter(|schema| {
3294 schema.downloads_weights()
3295 && !self
3296 .model_management
3297 .car_enabled(&schema.id)
3298 .unwrap_or(false)
3299 })
3300 .map(|schema| schema.id.clone())
3301 .collect::<Vec<_>>();
3302 for model_id in disabled {
3303 registry.unregister(&model_id);
3304 }
3305 }
3306
3307 async fn routing_registry_snapshot_with_credential_failure(
3308 &self,
3309 ) -> (UnifiedRegistry, Option<RouteCredentialFailure>) {
3310 let parslee = car_auth::resolve_credential(car_auth::CredentialReadMode::Use).await;
3316 let (parslee_api_base, parslee_signed_out, credential_failure) = match &parslee {
3317 Ok(Some(credential)) => (Some(credential.api_base.as_str()), false, None),
3318 Ok(None) => (None, true, Some(parslee_signed_out_route_failure())),
3319 Err(error) => {
3320 let source_error = error.to_string();
3321 (
3322 None,
3323 false,
3324 Some(RouteCredentialFailure {
3325 summary: format!(
3326 "Parslee {AUTH_STORE_UNREADABLE_MARKER} — unlock or grant access to the credential store, then retry"
3327 ),
3328 source_error,
3329 }),
3330 )
3331 }
3332 };
3333 let mut registry = self.unified_registry.clone();
3334 registry.refresh_routing_availability(parslee_api_base, parslee_signed_out);
3335 self.filter_disabled_local_models(&mut registry);
3336 (registry, credential_failure)
3337 }
3338
3339 async fn routing_registry_snapshot(&self) -> UnifiedRegistry {
3340 self.routing_registry_snapshot_with_credential_failure()
3341 .await
3342 .0
3343 }
3344
3345 async fn request_routing_registry_snapshot(
3350 &self,
3351 requested_model: Option<&str>,
3352 ) -> UnifiedRegistry {
3353 if requested_model.is_some() {
3354 self.catalog_registry_snapshot()
3355 } else {
3356 self.routing_registry_snapshot().await
3357 }
3358 }
3359
3360 pub async fn route_adaptive(&self, prompt: &str) -> AdaptiveRoutingDecision {
3362 self.route_adaptive_with_intent(prompt, None).await
3363 }
3364
3365 pub async fn route_adaptive_with_intent(
3375 &self,
3376 prompt: &str,
3377 intent: Option<crate::intent::IntentHint>,
3378 ) -> AdaptiveRoutingDecision {
3379 if let Some(model) = self.preferred_model_for_capability(ModelCapability::Generate) {
3380 let exclude_set = self
3381 .adaptive_router
3382 .build_exclude_set(intent.as_ref(), &self.unified_registry);
3383 let overridden_is_excluded =
3384 Self::model_is_excluded(&exclude_set, &self.unified_registry, model);
3385 if !overridden_is_excluded {
3386 let ctx_len = self
3392 .unified_registry
3393 .get(model)
3394 .or_else(|| self.unified_registry.find_by_name(model))
3395 .map(|s| s.context_length)
3396 .unwrap_or(0);
3397 return AdaptiveRoutingDecision {
3398 model_id: model.to_string(),
3399 model_name: model.to_string(),
3400 task: InferenceTask::Generate,
3401 complexity: TaskComplexity::assess(prompt),
3402 reason: "preferred generation model override".into(),
3403 strategy: RoutingStrategy::Explicit,
3404 predicted_quality: 0.5,
3405 fallbacks: vec![],
3406 context_length: ctx_len,
3407 needs_compaction: false,
3408 candidates: vec![],
3409 };
3410 }
3411 }
3412 let routing_registry = self.routing_registry_snapshot().await;
3413 let tracker = self.outcome_tracker.read().await;
3414 match intent {
3415 Some(hint) => self
3416 .adaptive_router
3417 .route_with(crate::adaptive_router::RouteRequest {
3418 intent: Some(&hint),
3419 ..crate::adaptive_router::RouteRequest::new(prompt, &routing_registry, &tracker)
3420 }),
3421 None => self
3422 .adaptive_router
3423 .route(prompt, &routing_registry, &tracker),
3424 }
3425 }
3426
3427 pub fn route(&self, prompt: &str) -> RoutingDecision {
3429 self.router.route_generate(prompt, &self.registry)
3430 }
3431
3432 pub fn estimated_tokens(
3443 &self,
3444 req: &GenerateRequest,
3445 model_id: Option<&str>,
3446 ) -> (usize, usize, bool) {
3447 let prompt_tokens = remote::estimate_tokens(&req.prompt);
3448 let context_tokens = req
3449 .context
3450 .as_ref()
3451 .map(|c| remote::estimate_tokens(c))
3452 .unwrap_or(0);
3453 let tools_tokens = req
3454 .tools
3455 .as_ref()
3456 .map(|t| remote::estimate_tokens(&serde_json::to_string(t).unwrap_or_default()))
3457 .unwrap_or(0);
3458 let media_tokens = media_tokens::request_media_and_history_tokens(
3459 req.images.as_deref(),
3460 req.messages.as_deref(),
3461 );
3462 let total_input = prompt_tokens + context_tokens + tools_tokens + media_tokens;
3463
3464 let context_window = match model_id {
3473 Some(id) => {
3474 let routing_registry = self.catalog_registry_snapshot();
3475 routing_registry
3476 .get(id)
3477 .or_else(|| routing_registry.find_by_name(id))
3478 .map(|s| s.context_length)
3479 .unwrap_or(0)
3480 }
3481 None => 0,
3482 };
3483
3484 let fits = context_window == 0 || (total_input + req.params.max_tokens) <= context_window;
3485 (total_input, context_window, fits)
3486 }
3487
3488 fn routing_cache_estimates(req: &GenerateRequest, estimated_input: usize) -> (usize, usize) {
3494 let read = req
3495 .params
3496 .estimated_cache_read_input_tokens
3497 .min(estimated_input);
3498 let write = req
3499 .params
3500 .estimated_cache_write_input_tokens
3501 .min(estimated_input.saturating_sub(read));
3502 (read, write)
3503 }
3504
3505 pub fn model_context_window(&self, model_id: &str) -> usize {
3511 let routing_registry = self.catalog_registry_snapshot();
3512 routing_registry
3513 .get(model_id)
3514 .or_else(|| routing_registry.find_by_name(model_id))
3515 .map(|s| s.context_length)
3516 .unwrap_or(0)
3517 }
3518
3519 pub async fn generate_tracked(
3534 &self,
3535 req: GenerateRequest,
3536 ) -> Result<InferenceResult, InferenceError> {
3537 crate::offload::ensure_not_controlled_terminated()?;
3538 let catalog_snapshot = self
3539 .catalog_snapshot()
3540 .map_err(InferenceError::InferenceFailed)?;
3541 let recover = matches!(req.params.thinking, ThinkingMode::Auto);
3542 let mut result = self
3543 .generate_tracked_inner(req.clone(), &catalog_snapshot)
3544 .await?;
3545
3546 let action = classify_empty_pass(
3547 recover,
3548 result.stop_reason.as_deref(),
3549 &result.text,
3550 result.tool_calls.is_empty(),
3551 );
3552 let hit_decode_ceiling = action == EmptyPassAction::FailDecodeCeiling;
3553
3554 if action == EmptyPassAction::RetryWithoutThinking {
3555 result.stop_reason = Some("thinking_truncated".to_string());
3556 let mut retry = req;
3557 retry.params.thinking = ThinkingMode::Off;
3558 crate::offload::ensure_not_controlled_terminated()?;
3559 match self.generate_tracked_inner(retry, &catalog_snapshot).await {
3560 Ok(mut recovered) => {
3561 if !recovered.text.trim().is_empty() || !recovered.tool_calls.is_empty() {
3562 recovered.stop_reason = Some("thinking_recovered".to_string());
3563 return Ok(recovered);
3564 }
3565 }
3566 Err(error @ InferenceError::ControlledTermination) => return Err(error),
3567 Err(_) => {}
3568 }
3569 }
3570
3571 if hit_decode_ceiling && result.text.trim().is_empty() && result.tool_calls.is_empty() {
3577 return Err(InferenceError::InferenceFailed(format!(
3578 "local generation hit its {}s wall-clock ceiling before producing any output. \
3579 The `local prefill starting` / `local decode in progress` log lines show where \
3580 the time went — a large prompt can spend most of it on prefill. Try a smaller \
3581 model, shorten the prompt, check for another process contending for the GPU, \
3582 or raise the ceiling with CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables it).",
3583 local_decode_timeout().map_or(0, |t| t.as_secs())
3584 )));
3585 }
3586
3587 Ok(result)
3588 }
3589
3590 #[instrument(
3591 name = "inference.generate",
3592 skip_all,
3593 fields(
3594 model = tracing::field::Empty,
3595 max_tokens = req.params.max_tokens,
3596 prompt_tokens = tracing::field::Empty,
3597 completion_tokens = tracing::field::Empty,
3598 latency_ms = tracing::field::Empty,
3599 )
3600 )]
3601 async fn generate_tracked_inner(
3602 &self,
3603 mut req: GenerateRequest,
3604 catalog_snapshot: &CatalogSnapshot,
3605 ) -> Result<InferenceResult, InferenceError> {
3606 crate::offload::ensure_not_controlled_terminated()?;
3607 validate_expected_catalog_revision(&req, catalog_snapshot)?;
3608 let requested_model_id = exact_pinned_model_id(&req).map(str::to_string);
3609 if let Some(model_id) = requested_model_id.as_ref() {
3610 req.model = Some(model_id.clone());
3611 req.params.strict_model = true;
3612 }
3613 let start = Instant::now();
3614 let has_requested_route = req.model.is_some();
3615 let (routing_registry, initial_route_credential_failure) = if has_requested_route {
3616 (self.catalog_registry_snapshot(), None)
3617 } else {
3618 self.routing_registry_snapshot_with_credential_failure()
3619 .await
3620 };
3621 if let Some(requested) = requested_model_id.as_deref() {
3622 if routing_registry.get(requested).is_none() {
3623 return Err(InferenceError::ModelNotFound(requested.to_string()));
3624 }
3625 } else if let Some(requested) = req.model.as_deref() {
3626 if routing_registry
3627 .get(requested)
3628 .or_else(|| routing_registry.find_by_name(requested))
3629 .is_none()
3630 {
3631 return Err(InferenceError::ModelNotFound(requested.to_string()));
3632 }
3633 }
3634
3635 let (estimated_input, _, _) = self.estimated_tokens(&req, None);
3637 let estimated_footprint = estimated_input.saturating_add(req.params.max_tokens);
3645 let (estimated_cache_read, estimated_cache_write) =
3646 Self::routing_cache_estimates(&req, estimated_input);
3647 let tracker_read = self.outcome_tracker.read().await;
3648 let has_tools = Self::request_has_tools(&req);
3649 let has_vision = Self::request_needs_vision(&req);
3650 let preferred_model = self
3651 .preferred_model_for_capability(ModelCapability::Generate)
3652 .map(str::to_string);
3653 let exclude_set = self
3654 .adaptive_router
3655 .build_exclude_set(req.intent.as_ref(), &routing_registry);
3656 let unpinned_override = self
3657 .lane_pin_for(&req, &routing_registry)
3658 .or(preferred_model)
3659 .filter(|model| !Self::model_is_excluded(&exclude_set, &routing_registry, model));
3660 let decision = match req.model.clone().or(unpinned_override) {
3661 Some(m) => {
3662 let ctx_len = routing_registry
3663 .get(&m)
3664 .or_else(|| routing_registry.find_by_name(&m))
3665 .map(|s| s.context_length)
3666 .unwrap_or(0);
3667 AdaptiveRoutingDecision {
3668 model_id: m.clone(),
3669 model_name: m.clone(),
3670 task: InferenceTask::Generate,
3671 complexity: TaskComplexity::assess(&req.prompt),
3672 reason: "explicit model".into(),
3673 strategy: RoutingStrategy::Explicit,
3674 predicted_quality: 0.5,
3675 fallbacks: vec![],
3676 context_length: ctx_len,
3677 needs_compaction: ctx_len > 0 && estimated_footprint > ctx_len,
3678 candidates: vec![],
3679 }
3680 }
3681 None => self
3682 .adaptive_router
3683 .route_with(crate::adaptive_router::RouteRequest {
3684 estimated_total_tokens: estimated_footprint,
3685 estimated_input_tokens: estimated_input,
3686 estimated_output_tokens: req.params.max_tokens,
3687 estimated_cache_read_tokens: estimated_cache_read,
3688 estimated_cache_write_tokens: estimated_cache_write,
3689 has_tools,
3690 has_vision,
3691 workload: req.params.workload,
3692 intent: req.intent.as_ref(),
3693 ..crate::adaptive_router::RouteRequest::new(
3694 &req.prompt,
3695 &routing_registry,
3696 &tracker_read,
3697 )
3698 }),
3699 };
3700 drop(tracker_read);
3701
3702 if decision.model_id.is_empty() {
3703 let excluded_models = req
3704 .intent
3705 .as_ref()
3706 .map(|hint| hint.exclude_models.join(", "))
3707 .unwrap_or_default();
3708 return Err(InferenceError::NoEligibleModel { excluded_models });
3709 }
3710
3711 if decision.needs_compaction {
3712 tracing::info!(
3713 model = %decision.model_name,
3714 prompt_tokens = estimated_input,
3715 context_window = decision.context_length,
3716 "prompt exceeds model context window — compaction or truncation needed"
3717 );
3718 }
3719
3720 debug!(
3725 model = %decision.model_name,
3726 strategy = ?decision.strategy,
3727 reason = %decision.reason,
3728 "adaptive-routed generate request"
3729 );
3730
3731 let mut req = req;
3734
3735 if let Some(schema) = routing_registry
3741 .get(&decision.model_id)
3742 .or_else(|| routing_registry.find_by_name(&decision.model_id))
3743 {
3744 req.params.max_tokens = resolved_max_tokens(req.params.max_tokens, schema);
3745 }
3746
3747 let is_code_intent = is_explicit_code_intent(req.intent.as_ref());
3756 let is_complex = matches!(decision.complexity, TaskComplexity::Complex);
3757 if req.params.budget_tokens == 0 && (is_code_intent || is_complex) {
3758 let supports_thinking = routing_registry
3762 .get(&decision.model_id)
3763 .or_else(|| routing_registry.find_by_name(&decision.model_id))
3764 .map(|s| {
3765 s.supported_params
3766 .contains(&schema::GenerateParam::ExtendedThinking)
3767 })
3768 .unwrap_or(false);
3769 if let Some(budget) =
3770 auto_thinking_budget(is_code_intent, is_complex, supports_thinking)
3771 {
3772 req.params.budget_tokens = budget;
3773 tracing::info!(
3774 model = %decision.model_name,
3775 budget,
3776 code_intent = is_code_intent,
3777 "auto-enabled extended thinking"
3778 );
3779 }
3780 }
3781
3782 let mut models_to_try = vec![decision.model_id.clone()];
3784 models_to_try.extend(decision.fallbacks.iter().cloned());
3785
3786 let chain_has_local = models_to_try.iter().any(|m| {
3801 routing_registry
3802 .get(m)
3803 .or_else(|| routing_registry.find_by_name(m))
3804 .map(|s| s.is_local())
3805 .unwrap_or(false)
3806 });
3807 let mut local_last_resort_id = None;
3808 if should_append_local_last_resort(chain_has_local, req.params.strict_model) {
3809 if let Some(local) = self.first_installed_local_model(has_tools) {
3813 tracing::info!(
3814 local_model = %local,
3815 needs_tools = has_tools,
3816 "appended on-device model as last-resort fallback (chain was remote-only)"
3817 );
3818 models_to_try.push(local.clone());
3819 local_last_resort_id = Some(local);
3820 }
3821 } else if !chain_has_local && req.params.strict_model {
3822 tracing::info!(
3823 model = %decision.model_id,
3824 "strict_model set — not degrading to on-device; a remote failure will surface as an error"
3825 );
3826 }
3827
3828 let mut last_error = None;
3829 let mut auth_dead_lane: Option<String> = None;
3835 let mut route_credential_failure = if chain_includes_parslee_route(
3845 |m| {
3846 routing_registry
3847 .get(m)
3848 .or_else(|| routing_registry.find_by_name(m))
3849 },
3850 &models_to_try,
3851 ) {
3852 initial_route_credential_failure
3853 } else {
3854 None
3855 };
3856 let mut fallback_hops: Vec<FallbackFrom> = Vec::new();
3860
3861 let mut candidate_queue: std::collections::VecDeque<String> =
3868 models_to_try.iter().cloned().collect();
3869 let mut is_primary_attempt = true;
3870 while let Some(candidate_owned) = candidate_queue.pop_front() {
3871 crate::offload::ensure_not_controlled_terminated()?;
3872 let was_primary = is_primary_attempt;
3873 is_primary_attempt = false;
3874 let candidate_id = &candidate_owned;
3875 #[allow(unused_mut)]
3878 let mut schema = routing_registry
3879 .get(candidate_id)
3880 .or_else(|| routing_registry.find_by_name(candidate_id))
3881 .cloned();
3882
3883 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3891 if requested_model_id.is_none() {
3892 if let Some(ref s) = schema {
3893 if let Some(mlx_equiv) = routing_registry.resolve_mlx_equivalent(s) {
3894 tracing::info!(
3895 from = %s.id, to = %mlx_equiv.id,
3896 "redirecting GGUF model to MLX equivalent on Apple Silicon"
3897 );
3898 schema = Some(mlx_equiv.clone());
3899 }
3900 }
3901 }
3902
3903 let candidate_name = schema
3908 .as_ref()
3909 .map(|s| s.name.clone())
3910 .unwrap_or_else(|| candidate_id.clone());
3911
3912 if has_tools
3923 && schema
3924 .as_ref()
3925 .map(|s| !s.has_capability(ModelCapability::ToolUse))
3926 .unwrap_or(false)
3927 {
3928 let backend = schema
3929 .as_ref()
3930 .map(|s| if s.is_local() { "local" } else { "remote" })
3931 .unwrap_or("unknown");
3932 tracing::warn!(
3933 model = %candidate_id,
3934 backend,
3935 "tools requested but resolved model lacks ToolUse capability — skipping candidate"
3936 );
3937 let unsupported = InferenceError::UnsupportedMode {
3938 mode: "tool_use",
3939 backend,
3940 reason: "resolved model does not support structured tool calls; configure a tool-capable model (a remote API model, or run the vllm-mlx OpenAI-compatible server)",
3941 };
3942 record_fallback_from(&mut fallback_hops, &candidate_name, &unsupported);
3948 last_error = Some(unsupported);
3949 continue;
3950 }
3951
3952 let resolved_id = schema
3963 .as_ref()
3964 .map(|s| s.id.clone())
3965 .unwrap_or_else(|| candidate_id.clone());
3966 let reported_model_used = if requested_model_id.is_some() {
3967 resolved_id.clone()
3968 } else {
3969 candidate_name.clone()
3970 };
3971 validate_expected_catalog_row(&req, catalog_snapshot, &resolved_id)?;
3972
3973 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3979 if requested_model_id.is_some()
3980 && schema
3981 .as_ref()
3982 .is_some_and(|schema| matches!(schema.source, ModelSource::Local { .. }))
3983 {
3984 let pinned = InferenceError::InferenceFailed(format!(
3985 "exact model_id `{resolved_id}` names a GGUF/Candle row, but Candle is disabled on Apple Silicon; MLX-equivalent substitution is disabled for exact pins"
3986 ));
3987 record_fallback_from(&mut fallback_hops, &candidate_name, &pinned);
3988 last_error = Some(pinned);
3989 continue;
3990 }
3991
3992 let is_remote = schema
3993 .as_ref()
3994 .map(|s| s.is_remote() || s.is_vllm_mlx())
3995 .unwrap_or(false);
3996 let is_codex_cli = schema.as_ref().map(|s| s.is_codex_cli()).unwrap_or(false);
3997 let is_delegated = schema.as_ref().map(|s| s.is_delegated()).unwrap_or(false);
3998
3999 let attempt_trace = {
4004 let mut tracker = self.outcome_tracker.write().await;
4005 tracker.record_start(&resolved_id, decision.task, &decision.reason)
4006 };
4007
4008 if is_delegated {
4014 let runner = match runner::current_inference_runner() {
4015 Some(r) => r,
4016 None => {
4017 let msg = "model declares ModelSource::Delegated but no inference runner is registered";
4018 self.outcome_tracker
4019 .write()
4020 .await
4021 .record_failure(&attempt_trace, msg);
4022 let unregistered = InferenceError::InferenceFailed(msg.into());
4023 record_fallback_from(&mut fallback_hops, &candidate_name, &unregistered);
4024 last_error = Some(unregistered);
4025 continue;
4026 }
4027 };
4028 let (tx, mut rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
4029 let emitter = runner::EventEmitter::new(tx);
4030 let runner_req = req.clone();
4031 let runner_handle = AbortOnDropTask(Some(tokio::spawn(async move {
4032 runner.run(runner_req, emitter).await
4033 })));
4034 let mut accumulator = stream::StreamAccumulator::default();
4035 while let Some(evt) = rx.recv().await {
4036 accumulator.push(&evt);
4037 }
4038 let (acc_text, acc_tool_calls, acc_usage, acc_stop_reason) =
4067 accumulator.finish_with_usage();
4068 match runner_handle.join().await {
4069 Ok(Ok(runner_result)) => {
4070 let elapsed = start.elapsed().as_millis() as u64;
4071 let acc_text = if acc_text.trim().is_empty() {
4072 runner_result.text
4073 } else {
4074 acc_text
4075 };
4076 let acc_tool_calls = if acc_tool_calls.is_empty() {
4077 runner_result.tool_calls
4078 } else {
4079 acc_tool_calls
4080 };
4081 let est_out = acc_text.split_whitespace().count();
4088 {
4089 let mut tracker = self.outcome_tracker.write().await;
4090 tracker.record_complete(
4091 &attempt_trace,
4092 elapsed,
4093 estimated_input,
4094 est_out,
4095 );
4096 }
4097 let local_last_resort = report_local_last_resort_served(
4098 local_last_resort_id.as_deref(),
4099 candidate_id,
4100 &resolved_id,
4101 );
4102 return Ok(InferenceResult {
4103 text: acc_text,
4104 tool_calls: acc_tool_calls,
4105 bounding_boxes: vec![],
4106 trace_id: attempt_trace,
4107 model_used: reported_model_used,
4108 model_identity: bound_model_identity(
4109 catalog_snapshot,
4110 requested_model_id.as_deref(),
4111 &resolved_id,
4112 )?,
4113 latency_ms: elapsed,
4114 time_to_first_token_ms: None,
4115 usage: acc_usage,
4118 provider_output_items: vec![],
4119 thinking: vec![],
4122 stop_reason: acc_stop_reason,
4123 auth_fallback_from: auth_dead_lane.clone(),
4124 local_last_resort,
4125 fallback_from: fallback_hops.clone(),
4126 });
4127 }
4128 Ok(Err(e)) => {
4129 self.outcome_tracker
4130 .write()
4131 .await
4132 .record_failure(&attempt_trace, &e.to_string());
4133 let failed = InferenceError::InferenceFailed(e.to_string());
4140 record_auth_dead_lane(
4141 &mut auth_dead_lane,
4142 &candidate_name,
4143 &failed.to_string(),
4144 );
4145 record_route_credential_failure(
4146 &mut route_credential_failure,
4147 &candidate_name,
4148 &failed,
4149 was_primary && has_requested_route,
4150 );
4151 record_fallback_from(&mut fallback_hops, &candidate_name, &failed);
4152 last_error = Some(failed);
4153 continue;
4154 }
4155 Err(join_err) => {
4156 let msg = format!("runner task panicked: {join_err}");
4157 self.outcome_tracker
4158 .write()
4159 .await
4160 .record_failure(&attempt_trace, &msg);
4161 let failed = InferenceError::InferenceFailed(msg);
4167 record_fallback_from(&mut fallback_hops, &candidate_name, &failed);
4168 last_error = Some(failed);
4169 continue;
4170 }
4171 }
4172 }
4173
4174 let has_tools = Self::request_has_tools(&req);
4175
4176 let context = if has_tools
4178 && req.tools.as_ref().is_some_and(|t| {
4179 t.iter().any(|tool| {
4180 tool.get("function")
4181 .and_then(|f| f.get("name"))
4182 .and_then(|n| n.as_str())
4183 == Some("done")
4184 })
4185 }) {
4186 let base = req.context.as_deref().unwrap_or("");
4187 Some(format!(
4188 "{base}\n\nIMPORTANT: When calling the `done` tool, the `result` field MUST contain a DETAILED summary of everything you found and did. This is the ONLY output the user sees. Do NOT just say 'completed' — include specific findings, data, and conclusions."
4189 ))
4190 } else {
4191 req.context.clone()
4192 };
4193
4194 let mut captured_thinking: Vec<crate::tasks::generate::ThinkingBlock> = Vec::new();
4198 let mut captured_provider_output_items: Vec<serde_json::Value> = Vec::new();
4199 let mut local_reservation = if !is_delegated {
4205 match schema
4206 .as_ref()
4207 .filter(|schema| Self::reserve_in_outer_dispatch(schema))
4208 {
4209 Some(local_schema) => {
4210 match self.reserve_local_request(local_schema, estimated_footprint) {
4211 Ok(reservation) => Some(reservation),
4212 Err(error) if req.model.is_some() || req.params.strict_model => {
4213 self.outcome_tracker
4214 .write()
4215 .await
4216 .record_capability_rejection(
4217 &attempt_trace,
4218 &error.to_string(),
4219 );
4220 return Err(error);
4221 }
4222 Err(error) => {
4223 tracing::warn!(
4224 model = %local_schema.id,
4225 error = %error,
4226 "adaptive local candidate blocked by resource policy; trying next route"
4227 );
4228 self.outcome_tracker
4229 .write()
4230 .await
4231 .record_capability_rejection(
4232 &attempt_trace,
4233 &error.to_string(),
4234 );
4235 record_fallback_from(&mut fallback_hops, &candidate_name, &error);
4240 last_error = Some(error);
4241 continue;
4242 }
4243 }
4244 }
4245 None => None,
4246 }
4247 } else {
4248 None
4249 };
4250 let result = if is_codex_cli {
4251 let schema_ref = schema
4252 .as_ref()
4253 .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4254 if req.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
4255 Err(InferenceError::UnsupportedMode {
4256 mode: "tools",
4257 backend: "codex-cli",
4258 reason: "the subscription-backed Codex source is a side-effect-free text generator and does not accept tools",
4259 })
4260 } else if req
4261 .messages
4262 .as_ref()
4263 .is_some_and(|messages| !messages.is_empty())
4264 {
4265 Err(InferenceError::UnsupportedMode {
4266 mode: "multi-turn-messages",
4267 backend: "codex-cli",
4268 reason: "the subscription-backed Codex source accepts one prompt plus optional context; it does not resume or replay conversations",
4269 })
4270 } else if req.images.as_ref().is_some_and(|images| !images.is_empty())
4271 || Self::request_has_video(&req)
4272 || Self::request_has_audio(&req)
4273 {
4274 Err(InferenceError::UnsupportedMode {
4275 mode: "multimodal-content",
4276 backend: "codex-cli",
4277 reason: "the subscription-backed Codex source is text-only",
4278 })
4279 } else if req.response_format.is_some() {
4280 Err(InferenceError::UnsupportedMode {
4281 mode: "response-format",
4282 backend: "codex-cli",
4283 reason: "the subscription-backed Codex source returns plain text and does not expose provider-enforced schemas",
4284 })
4285 } else {
4286 let model = match &schema_ref.source {
4287 ModelSource::CodexCli { model } => model,
4288 _ => unreachable!("is_codex_cli matched a different source"),
4289 };
4290 crate::backend::codex_cli::generate(
4291 model,
4292 &req.prompt,
4293 context.as_deref(),
4294 req.params.max_tokens,
4295 schema_ref.context_length,
4296 )
4297 .await
4298 .map(|output| (output.text, vec![], Some(output.usage), None, None))
4299 }
4300 } else if is_remote {
4301 let (schema_val, _remote_request_reservation) = match self
4305 .vllm_live_schema(
4306 schema.unwrap(),
4307 local_reservation.take(),
4308 estimated_footprint,
4309 )
4310 .await
4311 {
4312 Ok(pair) => pair,
4313 Err(e) => {
4314 self.outcome_tracker
4315 .write()
4316 .await
4317 .record_failure(&attempt_trace, &e.to_string());
4318 record_fallback_from(&mut fallback_hops, &candidate_name, &e);
4319 last_error = Some(e);
4320 continue;
4321 }
4322 };
4323 let _ctx_len = schema_val.context_length;
4324 let temperature = if !schema_val.supported_params.is_empty()
4327 && !schema_val
4328 .supported_params
4329 .contains(&crate::schema::GenerateParam::Temperature)
4330 {
4331 -1.0
4332 } else {
4333 req.params.temperature
4334 };
4335
4336 self.remote_backend
4342 .generate_with_tools_multi(
4343 &schema_val,
4344 &req.prompt,
4345 context.as_deref(),
4346 temperature,
4347 req.params.max_tokens,
4348 req.tools.as_deref(),
4349 req.images.as_deref(),
4350 req.messages.as_deref(),
4351 req.params.tool_choice.as_deref(),
4352 req.params.parallel_tool_calls,
4353 req.params.budget_tokens,
4354 req.cache_control,
4355 req.params.cache_ttl,
4356 req.context_stable_prefix.as_deref(),
4357 req.response_format.as_ref(),
4358 )
4359 .await
4360 .map(|(t, c, thinking, provider_items, u, stop)| {
4366 captured_thinking = thinking;
4367 captured_provider_output_items = provider_items;
4368 (t, c, u, None::<u64>, stop)
4369 })
4370 } else if let Some(offload) = schema
4371 .as_ref()
4372 .filter(|schema| Self::supports_worker_offload(schema))
4373 .and_then(|_| crate::offload::current_local_offload())
4374 {
4375 let mut offload_req = req.clone();
4386 offload_req.model = Some(resolved_id.clone());
4387 let schema_ref = schema
4388 .as_ref()
4389 .ok_or_else(|| InferenceError::ModelNotFound(resolved_id.clone()))?;
4390 let reservation = local_reservation.as_mut().ok_or_else(|| {
4391 InferenceError::InferenceFailed(
4392 "local worker dispatch missing admission reservation".into(),
4393 )
4394 })?;
4395 if let Some(allocation_id) = offload.resident_allocation_id(&resolved_id) {
4396 reservation.bind_allocation_id(&allocation_id);
4402 }
4403 let admission = self.prepare_worker_admission(schema_ref, reservation)?;
4404 match offload.generate_admitted(offload_req, admission).await {
4405 Ok(outcome) => {
4406 Self::reconcile_worker_residency(
4407 offload.as_ref(),
4408 &resolved_id,
4409 &outcome.residency,
4410 outcome.retention,
4411 reservation,
4412 )
4413 .await?;
4414 let ir = outcome.result;
4415 captured_thinking = ir.thinking;
4422 captured_provider_output_items = ir.provider_output_items;
4423 Ok((
4424 ir.text,
4425 ir.tool_calls,
4426 ir.usage,
4427 ir.time_to_first_token_ms,
4428 ir.stop_reason,
4429 ))
4430 }
4431 Err(e) => Err(e),
4432 }
4433 } else {
4434 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4435 {
4436 let schema_ref = schema
4440 .as_ref()
4441 .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4442
4443 if schema_ref.is_foundation_models() {
4451 if Self::request_has_video(&req)
4452 || Self::request_has_audio(&req)
4453 || req.images.as_ref().is_some_and(|imgs| !imgs.is_empty())
4454 {
4455 Err(InferenceError::UnsupportedMode {
4456 mode: "multimodal-content",
4457 backend: "foundation-models",
4458 reason: "the FoundationModels bridge currently exposes text-only \
4459 generation — route image/audio/video to a remote VL model",
4460 })
4461 } else if has_tools {
4462 if req.response_format.is_some() {
4468 tracing::warn!(
4469 "FoundationModels: response_format is ignored when tools \
4470 are present — one turn is either tool-enabled or \
4471 schema-constrained"
4472 );
4473 }
4474 let prompt = req.prompt.clone();
4475 let instructions = context.clone();
4476 let tools_defs = req.tools.clone().unwrap_or_default();
4477 let max_tokens = req.params.max_tokens as u32;
4478 let temperature = req.params.temperature;
4479 tokio::task::spawn_blocking(move || {
4480 crate::backend::foundation_models::generate_with_tools(
4481 &prompt,
4482 instructions.as_deref(),
4483 &tools_defs,
4484 max_tokens,
4485 temperature as f32,
4486 )
4487 })
4488 .await
4489 .map_err(|e| {
4490 InferenceError::InferenceFailed(format!(
4491 "FoundationModels task panicked: {e}"
4492 ))
4493 })
4494 .and_then(|r| r)
4495 .map(|(text, calls)| (text, calls, None, None, None))
4496 } else if let Some(crate::tasks::generate::ResponseFormat::JsonSchema {
4497 schema,
4498 ..
4499 }) = &req.response_format
4500 {
4501 let prompt = req.prompt.clone();
4505 let instructions = context.clone();
4506 let schema_val = schema.clone();
4507 let max_tokens = req.params.max_tokens as u32;
4508 let temperature = req.params.temperature;
4509 tokio::task::spawn_blocking(move || {
4510 crate::backend::foundation_models::generate_structured(
4511 &prompt,
4512 instructions.as_deref(),
4513 &schema_val,
4514 max_tokens,
4515 temperature as f32,
4516 )
4517 })
4518 .await
4519 .map_err(|e| {
4520 InferenceError::InferenceFailed(format!(
4521 "FoundationModels task panicked: {e}"
4522 ))
4523 })
4524 .and_then(|r| r)
4525 .map(|text| (text, vec![], None, None, None))
4526 } else {
4527 let instructions = if matches!(
4531 req.response_format,
4532 Some(crate::tasks::generate::ResponseFormat::JsonObject)
4533 ) {
4534 tracing::warn!(
4535 "FoundationModels: JsonObject response_format has no native \
4536 constrained mode — enforcing via instruction injection"
4537 );
4538 let base = context.clone().unwrap_or_default();
4539 Some(format!(
4540 "{base}\n\nRespond with a single valid JSON object and \
4541 nothing else."
4542 ))
4543 } else {
4544 context.clone()
4545 };
4546 let prompt = req.prompt.clone();
4547 let max_tokens = req.params.max_tokens as u32;
4548 let temperature = req.params.temperature;
4549 tokio::task::spawn_blocking(move || {
4550 crate::backend::foundation_models::generate(
4551 &prompt,
4552 instructions.as_deref(),
4553 max_tokens,
4554 temperature as f32,
4555 )
4556 })
4557 .await
4558 .map_err(|e| {
4559 InferenceError::InferenceFailed(format!(
4560 "FoundationModels task panicked: {e}"
4561 ))
4562 })
4563 .and_then(|r| r)
4564 .map(|text| (text, vec![], None, None, None))
4565 }
4566 } else if !schema_ref.is_mlx() {
4567 Err(InferenceError::InferenceFailed(format!(
4568 "model '{}' has no MLX equivalent; Candle backend disabled on Apple Silicon",
4569 schema_ref.id
4570 )))
4571 } else if schema_ref.tags.iter().any(|t| t == "mlx-vlm-cli") {
4572 let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
4584 if !has_images {
4585 return Err(InferenceError::UnsupportedMode {
4586 mode: "text-only-on-mlx-vlm-id",
4587 backend: "mlx-vlm-cli",
4588 reason: "the `mlx-vlm/...` model IDs route exclusively \
4589 through the mlx-vlm CLI for image inference. \
4590 For text-only generation, route to a Qwen3 \
4591 text model (`mlx/qwen3-4b:4bit` etc.) — the \
4592 CLI shell-out has higher latency than the \
4593 in-process MLX text tower.",
4594 });
4595 }
4596 let vlm_status = crate::backend::mlx_vlm_cli::runtime_status();
4597 if !vlm_status.is_available() {
4598 return Err(InferenceError::InferenceFailed(vlm_status.user_message()));
4599 }
4600 let model_dir = self.unified_registry.ensure_local(&schema_ref.id).await?;
4601 let reservation = local_reservation
4602 .as_mut()
4603 .expect("local VLM branch has admission reservation");
4604 Self::reconcile_transient_local_allocation(
4605 reservation,
4606 backend_cache::estimate_model_size(&model_dir),
4607 )?;
4608 let detached_lease = reservation.detached_lease();
4609 let repo = match &schema_ref.source {
4610 crate::schema::ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
4611 _ => {
4612 return Err(InferenceError::InferenceFailed(format!(
4613 "model '{}' is tagged mlx-vlm-cli but its \
4614 source isn't ModelSource::Mlx — registry bug",
4615 schema_ref.id
4616 )));
4617 }
4618 };
4619 let imgs = req.images.clone().unwrap_or_default();
4620 let temp = req.params.temperature;
4621 let max_t = req.params.max_tokens;
4622 let prompt = req.prompt.clone();
4623 let (text, cli_usage) = run_admitted_blocking(detached_lease, move || {
4624 crate::backend::mlx_vlm_cli::generate(
4625 &repo, &prompt, &imgs, temp, max_t,
4626 )
4627 })
4628 .await
4629 .map_err(|e| {
4630 InferenceError::InferenceFailed(format!(
4631 "mlx_vlm CLI task panicked: {e}"
4632 ))
4633 })??;
4634 let bounding_boxes = parse_boxes(&text);
4635 let latency_ms = start.elapsed().as_millis() as u64;
4636 let usage = cli_usage.map(|u| TokenUsage {
4646 prompt_tokens: u.prompt_tokens,
4647 completion_tokens: u.completion_tokens,
4648 total_tokens: u.prompt_tokens + u.completion_tokens,
4649 context_window: schema_ref.context_length as u64,
4650 ..Default::default()
4652 });
4653 {
4654 let (in_tokens, out_tokens) = match &usage {
4660 Some(u) => (u.prompt_tokens as usize, u.completion_tokens as usize),
4661 None => (estimated_input, text.split_whitespace().count()),
4662 };
4663 let mut tracker = self.outcome_tracker.write().await;
4664 tracker.record_complete(
4665 &attempt_trace,
4666 latency_ms,
4667 in_tokens,
4668 out_tokens,
4669 );
4670 }
4671 let local_last_resort = report_local_last_resort_served(
4672 local_last_resort_id.as_deref(),
4673 candidate_id,
4674 &resolved_id,
4675 );
4676 return Ok(InferenceResult {
4677 text,
4678 tool_calls: vec![],
4679 bounding_boxes,
4680 trace_id: attempt_trace,
4681 model_used: schema_ref.id.clone(),
4682 model_identity: bound_model_identity(
4683 catalog_snapshot,
4684 requested_model_id.as_deref(),
4685 &resolved_id,
4686 )?,
4687 latency_ms,
4688 time_to_first_token_ms: None,
4689 usage,
4690 provider_output_items: Vec::new(),
4691 thinking: Vec::new(), stop_reason: None,
4693 auth_fallback_from: auth_dead_lane.clone(),
4694 local_last_resort,
4695 fallback_from: fallback_hops.clone(),
4696 });
4697 } else if !Self::supports_native_mlx(schema_ref) {
4698 if req.images.as_ref().is_some_and(|i| !i.is_empty())
4705 || Self::request_has_video(&req)
4706 || Self::request_has_audio(&req)
4707 {
4708 return Err(InferenceError::UnsupportedMode {
4709 mode: "multimodal-content-block",
4710 backend: "native-mlx-local",
4711 reason: "this in-process MLX backend is text-only; route \
4712 image/video/audio understanding to a vLLM-MLX or remote \
4713 multimodal model",
4714 });
4715 }
4716 self.generate_local(
4727 req.clone(),
4728 &schema_ref.id,
4729 local_reservation
4730 .as_mut()
4731 .expect("local branch has admission reservation"),
4732 )
4733 .await
4734 } else {
4735 let (handle, _retention) = self
4745 .ensure_mlx_backend(
4746 schema_ref,
4747 local_reservation
4748 .as_mut()
4749 .expect("local branch has admission reservation"),
4750 )
4751 .await?;
4752 if Self::request_has_video(&req) {
4758 return Err(InferenceError::UnsupportedMode {
4759 mode: "video-content-block",
4760 backend: "native-mlx-qwen25vl",
4761 reason: "Qwen2.5-VL video understanding is on the request surface \
4762 but the video-tokenization path (frame sampling + merger) \
4763 is not yet wired; route to a remote VL provider for now",
4764 });
4765 }
4766 if Self::request_has_audio(&req) {
4767 return Err(InferenceError::UnsupportedMode {
4768 mode: "audio-content-block",
4769 backend: "native-mlx-qwen25vl",
4770 reason: "audio understanding is on the request surface (Gemma 4 \
4771 E2B/E4B and Gemini accept it) but the native MLX path \
4772 for this model does not — route to Gemini or Gemma-4",
4773 });
4774 }
4775 let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
4776 if has_images {
4777 let can_do_vision = {
4778 let guard = handle.lock().map_err(|_| {
4779 InferenceError::InferenceFailed(
4780 "MLX backend mutex poisoned".into(),
4781 )
4782 })?;
4783 guard.supports_capability(crate::schema::ModelCapability::Vision)
4784 };
4785 if !can_do_vision {
4786 return Err(InferenceError::UnsupportedMode {
4796 mode: "image-content-block",
4797 backend: "native-mlx-text",
4798 reason: "this MLX backend is a plain Qwen3 text tower. \
4799 For local image inference, route to \
4800 `mlx-vlm/qwen3-vl-2b:bf16` or another `mlx-vlm/...` \
4801 catalog ID so CAR shells out to `mlx_vlm.generate`. \
4802 Alternatives: a local vLLM-MLX VLM server, or a \
4803 remote VL model. (#115)",
4804 });
4805 }
4806 }
4807 self.generate_mlx(
4808 render_for_local_backend(req.clone()),
4809 &schema_ref.id,
4810 local_reservation
4811 .as_mut()
4812 .expect("local branch has admission reservation"),
4813 )
4814 .await
4815 .map(|(text, usage, ttft, stop)| (text, vec![], usage, ttft, stop))
4816 }
4817 }
4818
4819 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4820 {
4821 let schema_ref = schema
4822 .as_ref()
4823 .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4824 match self
4825 .ensure_backend(
4826 schema_ref,
4827 local_reservation
4828 .as_mut()
4829 .expect("local branch has admission reservation"),
4830 )
4831 .await
4832 {
4833 Ok(()) => {
4834 let mut write = self.backend.write().await;
4835 let backend = write.get_mut(&schema_ref.id).ok_or_else(|| {
4836 InferenceError::InferenceFailed(format!(
4837 "candle backend missing after ensure_backend for {}",
4838 schema_ref.id
4839 ))
4840 })?;
4841 let ctx_window = backend.context_length().unwrap_or(0) as u64;
4847 tasks::generate::generate(
4848 backend,
4849 render_for_local_backend(req.clone()),
4850 )
4851 .await
4852 .map(
4853 |(text, ttft, prompt_tokens, completion_tokens)| {
4854 let usage = TokenUsage {
4855 prompt_tokens: prompt_tokens as u64,
4856 completion_tokens: completion_tokens as u64,
4857 total_tokens: (prompt_tokens + completion_tokens) as u64,
4858 context_window: ctx_window,
4859 ..Default::default()
4862 };
4863 (text, vec![], Some(usage), ttft, None)
4864 },
4865 )
4866 }
4867 Err(e) => Err(e),
4868 }
4869 }
4870 };
4871
4872 match result {
4873 Ok((text, mut tool_calls, usage, time_to_first_token_ms, stop_reason)) => {
4874 let text = if !is_remote
4881 && !is_delegated
4882 && req.tools.is_some()
4883 && tool_calls.is_empty()
4884 {
4885 let (clean, parsed) = tasks::generate::parse_tool_calls(&text);
4886 tool_calls = parsed;
4887 clean
4888 } else {
4889 text
4890 };
4891 let latency_ms = start.elapsed().as_millis() as u64;
4892 let estimated_tokens = usage
4893 .as_ref()
4894 .map(|u| u.completion_tokens as usize)
4895 .unwrap_or_else(|| text.split_whitespace().count());
4896 let input_tokens = usage
4901 .as_ref()
4902 .map(|u| u.prompt_tokens as usize)
4903 .unwrap_or(estimated_input);
4904 let (cache_read, cache_creation) = usage
4909 .as_ref()
4910 .map(|u| {
4911 (
4912 u.cache_read_input_tokens as usize,
4913 u.cache_creation_input_tokens as usize,
4914 )
4915 })
4916 .unwrap_or((0, 0));
4917 {
4918 let mut tracker = self.outcome_tracker.write().await;
4919 tracker.record_complete_cached(
4920 &attempt_trace,
4921 latency_ms,
4922 input_tokens,
4923 estimated_tokens,
4924 cache_read,
4925 cache_creation,
4926 );
4927 }
4928 if let Ok(mut cb) = self.adaptive_router.circuit_breakers.lock() {
4934 cb.record_success(&resolved_id);
4935 }
4936 self.auto_save_outcomes().await;
4938
4939 let span = tracing::Span::current();
4941 span.record("model", candidate_name.as_str());
4942 span.record("latency_ms", latency_ms);
4943 if let Some(ttft) = time_to_first_token_ms {
4944 span.record("ttft_ms", ttft);
4945 }
4946 if let Some(ref u) = usage {
4947 span.record("prompt_tokens", u.prompt_tokens);
4948 span.record("completion_tokens", u.completion_tokens);
4949 }
4950
4951 let bounding_boxes = tasks::grounding::parse_boxes(&text);
4954 let local_last_resort = report_local_last_resort_served(
4955 local_last_resort_id.as_deref(),
4956 candidate_id,
4957 &resolved_id,
4958 );
4959 return Ok(InferenceResult {
4960 text,
4961 tool_calls,
4962 bounding_boxes,
4963 trace_id: attempt_trace,
4964 model_used: reported_model_used,
4965 model_identity: bound_model_identity(
4966 catalog_snapshot,
4967 requested_model_id.as_deref(),
4968 &resolved_id,
4969 )?,
4970 latency_ms,
4971 time_to_first_token_ms,
4972 usage,
4973 provider_output_items: captured_provider_output_items,
4974 thinking: captured_thinking,
4975 stop_reason,
4976 auth_fallback_from: auth_dead_lane.clone(),
4977 local_last_resort,
4978 fallback_from: fallback_hops.clone(),
4979 });
4980 }
4981 Err(e) => {
4982 if matches!(e, InferenceError::ControlledTermination) {
4983 self.outcome_tracker
4984 .write()
4985 .await
4986 .record_capability_rejection(&attempt_trace, &e.to_string());
4987 return Err(e);
4988 }
4989 tracing::warn!(
4990 model = %candidate_name,
4991 error = %e,
4992 remaining = candidate_queue.len(),
4993 "model failed, trying next fallback immediately"
4994 );
4995 record_auth_dead_lane(&mut auth_dead_lane, &candidate_name, &e.to_string());
5002 record_route_credential_failure(
5003 &mut route_credential_failure,
5004 &candidate_name,
5005 &e,
5006 was_primary && has_requested_route,
5007 );
5008 record_fallback_from(&mut fallback_hops, &candidate_name, &e);
5009 {
5014 let mut tracker = self.outcome_tracker.write().await;
5015 match &e {
5016 InferenceError::UnsupportedMode { .. } => {
5017 tracker.record_capability_rejection(&attempt_trace, &e.to_string())
5018 }
5019 InferenceError::ProviderAccount { .. } => {
5022 tracker.record_account_rejection(&attempt_trace, &e.to_string())
5023 }
5024 InferenceError::GatewayUnconfigured { .. } => {
5030 tracker.record_account_rejection(&attempt_trace, &e.to_string())
5031 }
5032 InferenceError::ContentRefused { .. } => {
5039 tracker.record_capability_rejection(&attempt_trace, &e.to_string())
5040 }
5041 _ => tracker.record_failure(&attempt_trace, &e.to_string()),
5042 }
5043 }
5044 if error_counts_against_circuit_breaker(&e) {
5054 let err_str = e.to_string();
5055 let is_client_error =
5056 err_str.contains("API returned 4") && !err_str.contains("429");
5057 if let Ok(mut cb) = self.adaptive_router.circuit_breakers.lock() {
5058 cb.record_failure(&resolved_id);
5060 if is_client_error {
5063 cb.record_failure(&resolved_id);
5064 }
5065 }
5066 }
5067 #[cfg(not(all(
5069 target_os = "macos",
5070 target_arch = "aarch64",
5071 not(car_skip_mlx)
5072 )))]
5073 {
5074 let mut write = self.backend.write().await;
5075 if write.remove(&resolved_id).is_some() {
5076 self.local_admission.mark_evicted(&resolved_id);
5077 }
5078 }
5079 if let InferenceError::GatewayUnconfigured { namespace, .. } = &e {
5107 let before = candidate_queue.len();
5108 candidate_queue.retain(|id| !id.starts_with(namespace.as_str()));
5109 let dropped = before - candidate_queue.len();
5110 if dropped > 0 {
5111 tracing::info!(
5112 %namespace,
5113 dropped,
5114 remaining = candidate_queue.len(),
5115 "gateway has no upstream for this namespace; dropping its \
5116 remaining candidates instead of replaying the same rejection"
5117 );
5118 }
5119 }
5120 if error_ends_fallback_chain(&e) {
5121 let dropped = candidate_queue.len();
5122 candidate_queue.clear();
5123 if dropped > 0 {
5124 tracing::info!(
5125 dropped,
5126 "content refused for this request; ending the fallback chain \
5127 rather than answering with a different model"
5128 );
5129 }
5130 }
5131 if let InferenceError::ProviderAccount { provider, .. } = &e {
5132 let mut rest: Vec<String> = candidate_queue.iter().cloned().collect();
5136 let demoted = routing_ext::demote_provider(provider, &mut rest, |id| {
5137 routing_registry
5138 .get(id)
5139 .or_else(|| routing_registry.find_by_name(id))
5140 .map(|s| s.provider.clone())
5141 });
5142 candidate_queue = rest.into();
5143 if demoted > 0 {
5144 tracing::info!(
5145 %provider,
5146 demoted,
5147 remaining = candidate_queue.len(),
5148 "account-level rejection; deferring this provider's \
5149 remaining candidates to the end of the chain"
5150 );
5151 }
5152 }
5153 if was_primary && !candidate_queue.is_empty() {
5154 let err_str = e.to_string();
5155 let status = parse_api_returned_status(&err_str);
5162 if routing_ext::is_provider_transient(status, &err_str) {
5163 let provider_of = |id: &str| {
5168 routing_registry
5169 .get(id)
5170 .or_else(|| routing_registry.find_by_name(id))
5171 .map(|s| s.provider.clone())
5172 };
5173 let primary = provider_of(candidate_id).unwrap_or_default();
5174 let queue_vec: Vec<String> = candidate_queue.iter().cloned().collect();
5175 if let Some(cross) =
5176 routing_ext::first_cross_provider(&primary, &queue_vec, provider_of)
5177 {
5178 let cross = cross.to_string();
5179 if let Some(pos) = candidate_queue.iter().position(|m| *m == cross)
5180 {
5181 if pos > 0 {
5182 if let Some(m) = candidate_queue.remove(pos) {
5183 tracing::info!(
5184 promoted = %m,
5185 "transient provider error on primary; promoting cross-provider fallback"
5186 );
5187 candidate_queue.push_front(m);
5188 }
5189 }
5190 }
5191 }
5192 }
5193 }
5194 last_error = Some(e);
5195 }
5196 }
5197 }
5198
5199 let underlying = last_error.unwrap_or(InferenceError::InferenceFailed(
5201 "no models available".into(),
5202 ));
5203
5204 let e = apply_route_failure_context(underlying, route_credential_failure.as_ref());
5218 self.auto_save_outcomes().await;
5222 Err(e)
5223 }
5224
5225 async fn generate_stream_raw(
5230 &self,
5231 req: GenerateRequest,
5232 ) -> Result<(String, tokio::sync::mpsc::Receiver<stream::StreamEvent>), InferenceError> {
5233 let routing_registry = self
5234 .request_routing_registry_snapshot(req.model.as_deref())
5235 .await;
5236 if let Some(requested) = req.model.as_deref() {
5237 if routing_registry
5238 .get(requested)
5239 .or_else(|| routing_registry.find_by_name(requested))
5240 .is_none()
5241 {
5242 return Err(InferenceError::ModelNotFound(requested.to_string()));
5243 }
5244 }
5245 let has_tools = Self::request_has_tools(&req);
5246 let has_vision = Self::request_needs_vision(&req);
5247 let (estimated_input, _, _) = self.estimated_tokens(&req, None);
5248 let estimated_footprint = estimated_input.saturating_add(req.params.max_tokens);
5249 let (estimated_cache_read, estimated_cache_write) =
5250 Self::routing_cache_estimates(&req, estimated_input);
5251 let preferred_model = self
5252 .preferred_model_for_capability(ModelCapability::Generate)
5253 .map(str::to_string);
5254 let exclude_set = self
5255 .adaptive_router
5256 .build_exclude_set(req.intent.as_ref(), &routing_registry);
5257 let unpinned_override = self
5258 .lane_pin_for(&req, &routing_registry)
5259 .or(preferred_model)
5260 .filter(|model| !Self::model_is_excluded(&exclude_set, &routing_registry, model));
5261 let decision = match req.model.clone().or(unpinned_override) {
5262 Some(m) => {
5263 let ctx_len = routing_registry
5264 .get(&m)
5265 .or_else(|| routing_registry.find_by_name(&m))
5266 .map(|s| s.context_length)
5267 .unwrap_or(0);
5268 AdaptiveRoutingDecision {
5269 model_id: m.clone(),
5270 model_name: m,
5271 task: InferenceTask::Generate,
5272 complexity: TaskComplexity::assess(&req.prompt),
5273 reason: "explicit model".into(),
5274 strategy: RoutingStrategy::Explicit,
5275 predicted_quality: 0.5,
5276 fallbacks: vec![],
5277 context_length: ctx_len,
5278 needs_compaction: false,
5279 candidates: vec![],
5280 }
5281 }
5282 None => {
5283 let tracker_read = self.outcome_tracker.read().await;
5284 self.adaptive_router
5285 .route_with(crate::adaptive_router::RouteRequest {
5286 estimated_total_tokens: estimated_footprint,
5287 estimated_input_tokens: estimated_input,
5288 estimated_output_tokens: req.params.max_tokens,
5289 estimated_cache_read_tokens: estimated_cache_read,
5290 estimated_cache_write_tokens: estimated_cache_write,
5291 has_tools,
5292 has_vision,
5293 workload: req.params.workload,
5294 intent: req.intent.as_ref(),
5295 ..crate::adaptive_router::RouteRequest::new(
5296 &req.prompt,
5297 &routing_registry,
5298 &tracker_read,
5299 )
5300 })
5301 }
5302 };
5303
5304 if decision.model_id.is_empty() {
5305 let excluded_models = req
5306 .intent
5307 .as_ref()
5308 .map(|hint| hint.exclude_models.join(", "))
5309 .unwrap_or_default();
5310 return Err(InferenceError::NoEligibleModel { excluded_models });
5311 }
5312
5313 #[allow(unused_mut)]
5316 let mut schema = routing_registry
5317 .get(&decision.model_id)
5318 .or_else(|| routing_registry.find_by_name(&decision.model_id))
5319 .cloned();
5320
5321 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
5323 if let Some(ref s) = schema {
5324 if let Some(mlx_equiv) = routing_registry.resolve_mlx_equivalent(s) {
5325 tracing::info!(
5326 from = %s.id, to = %mlx_equiv.id,
5327 "redirecting GGUF model to MLX equivalent on Apple Silicon (stream)"
5328 );
5329 schema = Some(mlx_equiv.clone());
5330 }
5331 }
5332
5333 if has_tools
5339 && schema
5340 .as_ref()
5341 .map(|s| !s.has_capability(ModelCapability::ToolUse))
5342 .unwrap_or(false)
5343 {
5344 let backend = schema
5345 .as_ref()
5346 .map(|s| if s.is_local() { "local" } else { "remote" })
5347 .unwrap_or("unknown");
5348 return Err(InferenceError::UnsupportedMode {
5349 mode: "tool_use",
5350 backend,
5351 reason: "resolved model does not support structured tool calls; configure a tool-capable model (a remote API model, or run the vllm-mlx OpenAI-compatible server)",
5352 });
5353 }
5354
5355 let resolved_model_id = schema
5357 .as_ref()
5358 .map(|s| s.id.clone())
5359 .unwrap_or_else(|| decision.model_id.clone());
5360
5361 let mut req = req;
5368 if let Some(schema) = schema.as_ref() {
5369 req.params.max_tokens = resolved_max_tokens(req.params.max_tokens, schema);
5370 }
5371
5372 let is_remote = schema
5373 .as_ref()
5374 .map(|s| s.is_remote() || s.is_vllm_mlx())
5375 .unwrap_or(false);
5376
5377 let is_codex_cli = schema.as_ref().map(|s| s.is_codex_cli()).unwrap_or(false);
5378 let is_delegated = schema.as_ref().map(|s| s.is_delegated()).unwrap_or(false);
5379
5380 if is_codex_cli {
5381 return Err(InferenceError::UnsupportedMode {
5382 mode: "streaming",
5383 backend: "codex-cli",
5384 reason: "codex exec reports final answer items rather than token deltas; use non-streaming infer/generate_tracked",
5385 });
5386 }
5387
5388 if is_delegated {
5389 let runner = runner::current_inference_runner().ok_or_else(|| {
5393 InferenceError::InferenceFailed(
5394 "model declares ModelSource::Delegated but no inference runner is registered \
5395 (call set_inference_runner / registerInferenceRunner / register_inference_runner)"
5396 .into(),
5397 )
5398 })?;
5399 let (tx, rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
5400 let emitter = runner::EventEmitter::new(tx);
5401 let request = req.clone();
5402 tokio::spawn(async move {
5403 if let Err(e) = runner.run(request, emitter).await {
5404 tracing::warn!(error = %e, "delegated inference runner failed");
5405 }
5406 });
5407 return Ok((resolved_model_id, rx));
5408 }
5409
5410 let mut local_reservation =
5412 if !is_remote || schema.as_ref().is_some_and(ModelSchema::is_vllm_mlx) {
5413 schema
5414 .as_ref()
5415 .filter(|schema| Self::reserve_in_outer_dispatch(schema))
5416 .map(|schema| self.reserve_local_request(schema, estimated_footprint))
5417 .transpose()?
5418 } else {
5419 None
5420 };
5421
5422 if !is_remote && schema.as_ref().is_some_and(Self::supports_worker_offload) {
5429 if let Some(offload) = crate::offload::current_local_offload() {
5430 let mut offload_req = req.clone();
5431 offload_req.model = Some(resolved_model_id.clone());
5432 let schema_ref = schema
5433 .as_ref()
5434 .ok_or_else(|| InferenceError::ModelNotFound(resolved_model_id.clone()))?;
5435 let reservation = local_reservation.as_mut().ok_or_else(|| {
5436 InferenceError::InferenceFailed(
5437 "local worker stream missing admission reservation".into(),
5438 )
5439 })?;
5440 if let Some(allocation_id) = offload.resident_allocation_id(&resolved_model_id) {
5441 reservation.bind_allocation_id(&allocation_id);
5442 }
5443 let admission = self.prepare_worker_admission(schema_ref, reservation)?;
5444 let offload_stream = offload.stream_admitted(offload_req, admission).await?;
5445 Self::reconcile_worker_residency(
5446 offload.as_ref(),
5447 &resolved_model_id,
5448 &offload_stream.residency,
5449 offload_stream.retention,
5450 reservation,
5451 )
5452 .await?;
5453 let rx = offload_stream.events;
5454 let rx = match local_reservation {
5455 Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
5456 None => rx,
5457 };
5458 return Ok((resolved_model_id, rx));
5459 }
5460 }
5461
5462 if is_remote {
5463 let mut candidates = vec![schema.unwrap()];
5464 let mut local_fallback_ids = Vec::new();
5465 for fallback_id in &decision.fallbacks {
5466 if let Some(fallback) = routing_registry
5467 .get(fallback_id)
5468 .or_else(|| routing_registry.find_by_name(fallback_id))
5469 {
5470 if (fallback.is_remote() || fallback.is_vllm_mlx())
5471 && (!has_tools || fallback.has_capability(ModelCapability::ToolUse))
5472 && (!has_vision || fallback.has_capability(ModelCapability::Vision))
5473 && !candidates
5474 .iter()
5475 .any(|candidate| candidate.id == fallback.id)
5476 {
5477 candidates.push(fallback.clone());
5478 } else if fallback.is_local()
5479 && !fallback.is_vllm_mlx()
5480 && (!has_tools || fallback.has_capability(ModelCapability::ToolUse))
5481 && (!has_vision || fallback.has_capability(ModelCapability::Vision))
5482 {
5483 local_fallback_ids.push(fallback.id.clone());
5484 }
5485 }
5486 }
5487 let mut last_error = None;
5488 for candidate in candidates {
5489 let (candidate, mut candidate_reservation) = match self
5492 .vllm_live_schema(candidate, local_reservation.take(), estimated_footprint)
5493 .await
5494 {
5495 Ok(candidate) => candidate,
5496 Err(error) => {
5497 last_error = Some(error);
5498 continue;
5499 }
5500 };
5501 self.remote_backend.register_model_keys(&candidate).await;
5502
5503 let spend_guard = self
5504 .spend_limits
5505 .read()
5506 .unwrap()
5507 .as_ref()
5508 .and_then(|limits| limits.per_request_usd)
5509 .map(|budget| {
5510 let mut prompt_tokens =
5511 routing_ext::MidStreamSpendGuard::estimate_tokens(&req.prompt)
5512 + req
5513 .context
5514 .as_deref()
5515 .map(routing_ext::MidStreamSpendGuard::estimate_tokens)
5516 .unwrap_or(0);
5517 prompt_tokens += media_tokens::request_media_and_history_tokens(
5518 req.images.as_deref(),
5519 req.messages.as_deref(),
5520 ) as u64;
5521 if let Some(tools) = &req.tools {
5522 prompt_tokens += routing_ext::MidStreamSpendGuard::estimate_tokens(
5523 &serde_json::to_string(tools).unwrap_or_default(),
5524 );
5525 }
5526 let prices = candidate.cost.prices_for(prompt_tokens as usize);
5527 let input_price = prices
5528 .input_per_mtok
5529 .map(|c| c / 1_000_000.0)
5530 .unwrap_or(0.0);
5531 let output_price = prices
5532 .output_per_mtok
5533 .map(|c| c / 1_000_000.0)
5534 .unwrap_or(0.0);
5535 routing_ext::MidStreamSpendGuard::new(
5536 Some(budget),
5537 prompt_tokens as f64 * input_price,
5538 input_price,
5539 output_price,
5540 )
5541 });
5542
5543 match self
5544 .remote_backend
5545 .generate_stream(
5546 &candidate,
5547 &req.prompt,
5548 req.messages.as_deref(),
5549 req.context.as_deref(),
5550 req.params.temperature,
5551 req.params.max_tokens,
5552 req.tools.as_deref(),
5553 req.images.as_deref(),
5554 req.params.tool_choice.as_deref(),
5555 req.params.parallel_tool_calls,
5556 req.response_format.as_ref(),
5557 spend_guard,
5558 )
5559 .await
5560 {
5561 Ok(receiver) => {
5562 let receiver = Self::hold_optional_reservation_for_stream(
5563 receiver,
5564 candidate_reservation.take(),
5565 );
5566 return Ok((candidate.id, receiver));
5567 }
5568 Err(error) => {
5569 tracing::warn!(
5570 model = %candidate.id,
5571 %error,
5572 "remote stream setup failed; trying routed fallback"
5573 );
5574 last_error = Some(error);
5575 }
5576 }
5577 }
5578
5579 if !req.params.strict_model {
5584 if let Some(local) = self.first_installed_local_model(has_tools) {
5585 let schema = routing_registry
5586 .get(&local)
5587 .or_else(|| routing_registry.find_by_name(&local));
5588 let supports_request = schema.is_some_and(|schema| {
5589 !has_vision || schema.has_capability(ModelCapability::Vision)
5590 });
5591 if supports_request
5592 && !local_fallback_ids.iter().any(|candidate| {
5593 routing_registry
5594 .get(candidate)
5595 .or_else(|| routing_registry.find_by_name(candidate))
5596 .is_some_and(|schema| schema.id == local || schema.name == local)
5597 })
5598 {
5599 local_fallback_ids.push(local);
5600 }
5601 }
5602 for local_id in local_fallback_ids {
5603 let mut fallback_req = req.clone();
5604 fallback_req.model = Some(local_id);
5605 fallback_req.params.strict_model = true;
5606 match Box::pin(self.generate_stream_raw(fallback_req)).await {
5607 Ok(stream) => return Ok(stream),
5608 Err(error) => {
5609 tracing::warn!(%error, "local streaming fallback failed");
5610 last_error = Some(error);
5611 }
5612 }
5613 }
5614 }
5615
5616 Err(last_error.unwrap_or_else(|| {
5617 InferenceError::InferenceFailed(
5618 "no compatible remote streaming model available".to_string(),
5619 )
5620 }))
5621 } else {
5622 let schema =
5623 schema.ok_or_else(|| InferenceError::ModelNotFound(decision.model_id.clone()))?;
5624 let (tx, rx) = tokio::sync::mpsc::channel(64);
5625
5626 #[cfg(any(
5632 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
5633 all(target_os = "ios", target_arch = "aarch64")
5634 ))]
5635 {
5636 if schema.is_foundation_models() {
5637 if Self::request_has_video(&req)
5641 || Self::request_has_audio(&req)
5642 || req.images.as_ref().is_some_and(|imgs| !imgs.is_empty())
5643 {
5644 return Err(InferenceError::UnsupportedMode {
5645 mode: "multimodal-content",
5646 backend: "foundation-models",
5647 reason: "the FoundationModels bridge currently exposes text-only \
5648 generation — route image/audio/video to a remote VL model",
5649 });
5650 }
5651 if let Some(tools_defs) = req.tools.clone().filter(|t| !t.is_empty()) {
5657 let prompt = req.prompt.clone();
5658 let instructions = req.context.clone();
5659 let max_tokens = req.params.max_tokens as u32;
5660 let temperature = req.params.temperature;
5661 tokio::task::spawn_blocking(move || {
5662 match crate::backend::foundation_models::generate_with_tools(
5663 &prompt,
5664 instructions.as_deref(),
5665 &tools_defs,
5666 max_tokens,
5667 temperature as f32,
5668 ) {
5669 Ok((text, tool_calls)) => {
5670 if !text.is_empty() {
5671 let _ = tx.blocking_send(stream::StreamEvent::TextDelta(
5672 text.clone(),
5673 ));
5674 }
5675 let _ = tx.blocking_send(stream::StreamEvent::Done {
5676 text,
5677 tool_calls,
5678 });
5679 }
5680 Err(e) => {
5681 tracing::warn!(
5686 error = %e,
5687 "FoundationModels tool-enabled stream failed"
5688 );
5689 }
5690 }
5691 });
5692 return Ok((resolved_model_id, rx));
5693 }
5694 let prompt = req.prompt.clone();
5695 let instructions = req.context.clone();
5696 let max_tokens = req.params.max_tokens as u32;
5697 let temperature = req.params.temperature;
5698 let tx_clone = tx.clone();
5699 tokio::task::spawn_blocking(move || {
5700 let accum = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
5707 let accum_cb = accum.clone();
5708 let cb = crate::backend::foundation_models::StreamCallback::new(
5709 move |delta: &str| {
5710 if let Ok(mut g) = accum_cb.lock() {
5711 g.push_str(delta);
5712 }
5713 tx_clone
5714 .blocking_send(stream::StreamEvent::TextDelta(
5715 delta.to_string(),
5716 ))
5717 .is_ok()
5718 },
5719 );
5720 let result = crate::backend::foundation_models::stream(
5721 &prompt,
5722 instructions.as_deref(),
5723 max_tokens,
5724 temperature as f32,
5725 cb,
5726 );
5727 let final_text = accum.lock().map(|g| g.clone()).unwrap_or_default();
5728 let _ = tx.blocking_send(stream::StreamEvent::Done {
5729 text: final_text,
5730 tool_calls: vec![],
5731 });
5732 result
5733 });
5734 return Ok((resolved_model_id, rx));
5735 }
5736 }
5737
5738 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
5740 {
5741 if !schema.is_mlx() {
5744 return Err(InferenceError::InferenceFailed(format!(
5745 "model '{}' has no MLX equivalent; Candle backend disabled on Apple Silicon",
5746 schema.id
5747 )));
5748 }
5749 let (backend, _retention) = self
5750 .ensure_mlx_backend(
5751 &schema,
5752 local_reservation
5753 .as_mut()
5754 .expect("local stream has admission reservation"),
5755 )
5756 .await?;
5757 let model_id = schema.id.clone();
5758 let cache = Arc::clone(&self.mlx_backends);
5759 let device_guard = Self::mlx_device_lock().lock_owned().await;
5766 tokio::task::spawn_blocking(move || {
5772 let _device_guard = device_guard;
5773 let _ = Self::stream_local_mlx(backend, cache, model_id, req, tx);
5774 });
5775 let rx = match local_reservation {
5776 Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
5777 None => rx,
5778 };
5779 Ok((resolved_model_id, rx))
5780 }
5781
5782 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5783 {
5784 self.ensure_backend(
5785 &schema,
5786 local_reservation
5787 .as_mut()
5788 .expect("local stream has admission reservation"),
5789 )
5790 .await?;
5791 let backend = self.backend.clone();
5792 let model_id = schema.id.clone();
5793 tokio::spawn(async move {
5794 let _ = Self::stream_local_candle(backend, model_id, req, tx).await;
5795 });
5796 let rx = match local_reservation {
5797 Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
5798 None => rx,
5799 };
5800 Ok((resolved_model_id, rx))
5801 }
5802 }
5803 }
5804
5805 pub async fn generate_tracked_stream(
5850 &self,
5851 req: GenerateRequest,
5852 ) -> Result<TrackedStream, InferenceError> {
5853 let (estimated_input, _, _) = self.estimated_tokens(&req, None);
5857 let start = Instant::now();
5858
5859 let (model_id, mut producer_rx) = self.generate_stream_raw(req).await?;
5863
5864 let trace = {
5865 let mut t = self.outcome_tracker.write().await;
5866 t.record_start(&model_id, InferenceTask::Generate, "stream")
5867 };
5868 let trace_for_return = trace.clone();
5872
5873 let (out_tx, out_rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
5874 let tracker = Arc::clone(&self.outcome_tracker);
5875 tokio::spawn(async move {
5876 let mut acc = stream::StreamAccumulator::default();
5877 let mut stream_error: Option<String> = None;
5878 let mut saw_done = false;
5879 let mut receiver_abandoned = false;
5880 while let Some(evt) = producer_rx.recv().await {
5881 if let stream::StreamEvent::Error(message) = &evt {
5882 stream_error = Some(message.clone());
5883 }
5884 if matches!(evt, stream::StreamEvent::Done { .. }) {
5885 saw_done = true;
5886 }
5887 acc.push(&evt);
5888 if out_tx.send(evt).await.is_err() {
5889 receiver_abandoned = true;
5890 break;
5891 }
5892 }
5893 if stream_error.is_none() && !saw_done && !receiver_abandoned {
5894 let error = "stream ended without positive provider completion".to_string();
5895 stream_error = Some(error.clone());
5896 let _ = out_tx.send(stream::StreamEvent::Error(error)).await;
5897 } else if stream_error.is_none() && receiver_abandoned {
5898 stream_error = Some("stream receiver was abandoned before completion".to_string());
5899 }
5900 let (text, tool_calls, usage, _stop) = acc.finish_with_usage();
5901 let latency_ms = start.elapsed().as_millis() as u64;
5902 let input_tokens = usage
5903 .as_ref()
5904 .map(|u| u.prompt_tokens as usize)
5905 .unwrap_or(estimated_input);
5906 let mut output_tokens = usage
5911 .as_ref()
5912 .map(|u| u.completion_tokens as usize)
5913 .unwrap_or_else(|| text.split_whitespace().count());
5914 if output_tokens == 0 && !tool_calls.is_empty() {
5915 output_tokens = 1;
5916 }
5917 let (cache_read, cache_creation) = usage
5922 .as_ref()
5923 .map(|u| {
5924 (
5925 u.cache_read_input_tokens as usize,
5926 u.cache_creation_input_tokens as usize,
5927 )
5928 })
5929 .unwrap_or((0, 0));
5930 let mut t = tracker.write().await;
5931 if let Some(error) = stream_error {
5932 t.record_failure(&trace, &error);
5933 } else {
5934 t.record_complete_cached(
5935 &trace,
5936 latency_ms,
5937 input_tokens,
5938 output_tokens,
5939 cache_read,
5940 cache_creation,
5941 );
5942 }
5943 });
5944
5945 Ok(TrackedStream {
5946 model_used: model_id,
5947 trace_id: trace_for_return,
5948 events: out_rx,
5949 })
5950 }
5951
5952 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5953 async fn stream_local_candle(
5954 backend_lock: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
5955 model_id: String,
5956 req: GenerateRequest,
5957 tx: tokio::sync::mpsc::Sender<stream::StreamEvent>,
5958 ) -> Result<(), InferenceError> {
5959 let mut write = backend_lock.write().await;
5960 let backend = write.get_mut(&model_id).ok_or_else(|| {
5961 InferenceError::InferenceFailed(format!("backend not initialized for {model_id}"))
5962 })?;
5963 backend.clear_kv_cache();
5964
5965 let formatted = tasks::generate::render_chat_prompt(&req);
5966 let tokens = backend.encode(&formatted)?;
5967 let eos = backend.eos_token_id();
5968 let eos_alt = backend.token_id("<|im_end|>");
5969 let params = &req.params;
5970
5971 if tokens.is_empty() {
5972 let _ = tx
5973 .send(stream::StreamEvent::Done {
5974 text: String::new(),
5975 tool_calls: vec![],
5976 })
5977 .await;
5978 return Ok(());
5979 }
5980
5981 let max_ctx = backend.context_length().unwrap_or(32768);
5982 let headroom = params.max_tokens.min(max_ctx / 4);
5983 let max_prompt = max_ctx.saturating_sub(headroom);
5984 let tokens = if tokens.len() > max_prompt {
5985 tokens[tokens.len() - max_prompt..].to_vec()
5986 } else {
5987 tokens
5988 };
5989
5990 let mut generated = Vec::new();
5991 let logits = backend.forward(&tokens, 0)?;
5992 let mut next_token = tasks::generate::sample_token(&logits, params)?;
5993
5994 for _ in 0..params.max_tokens {
5995 if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
5996 break;
5997 }
5998
5999 generated.push(next_token);
6000 let delta = backend.decode(&[next_token])?;
6001 if !delta.is_empty()
6002 && tx
6003 .send(stream::StreamEvent::TextDelta(delta))
6004 .await
6005 .is_err()
6006 {
6007 return Ok(());
6008 }
6009
6010 if !params.stop.is_empty() {
6011 let text_so_far = backend.decode(&generated)?;
6012 if params.stop.iter().any(|s| text_so_far.contains(s)) {
6013 break;
6014 }
6015 }
6016
6017 let pos = tokens.len() + generated.len() - 1;
6018 let logits = backend.forward(&[next_token], pos)?;
6019 next_token = tasks::generate::sample_token(&logits, params)?;
6020 }
6021
6022 let trimmed = tasks::generate::truncate_at_stop(&backend.decode(&generated)?, ¶ms.stop);
6023 let text = tasks::generate::strip_thinking(&trimmed, params.thinking);
6024 let _ = tx
6028 .send(stream::StreamEvent::Usage {
6029 input_tokens: tokens.len() as u64,
6030 output_tokens: generated.len() as u64,
6031 cache_read_input_tokens: 0,
6032 cache_creation_input_tokens: 0,
6033 })
6034 .await;
6035 let _ = tx
6036 .send(stream::StreamEvent::Done {
6037 text,
6038 tool_calls: vec![],
6039 })
6040 .await;
6041 Ok(())
6042 }
6043
6044 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6048 fn stream_local_mlx(
6049 handle: backend_cache::CachedBackend<backend::MlxBackend>,
6050 cache: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
6051 model_id: String,
6052 req: GenerateRequest,
6053 tx: tokio::sync::mpsc::Sender<stream::StreamEvent>,
6054 ) -> Result<(), InferenceError> {
6055 let mut guard = handle.lock().map_err(|_| {
6056 InferenceError::InferenceFailed(format!("MLX backend mutex poisoned for {model_id}"))
6057 })?;
6058 let backend: &mut backend::MlxBackend = &mut guard;
6059 backend.clear_kv_cache();
6060
6061 let formatted = tasks::generate::render_chat_prompt(&req);
6062 let tokens = backend.encode(&formatted)?;
6063 let eos = backend.eos_token_id();
6064 let eos_alt = backend.token_id("<|im_end|>");
6065 let params = &req.params;
6066
6067 if tokens.is_empty() {
6068 let _ = tx.blocking_send(stream::StreamEvent::Done {
6069 text: String::new(),
6070 tool_calls: vec![],
6071 });
6072 return Ok(());
6073 }
6074
6075 let max_ctx = backend.context_length();
6076 let headroom = params.max_tokens.min(max_ctx / 4);
6077 let max_prompt = max_ctx.saturating_sub(headroom);
6078 let tokens = if tokens.len() > max_prompt {
6079 tokens[tokens.len() - max_prompt..].to_vec()
6080 } else {
6081 tokens
6082 };
6083
6084 let mut generated = Vec::new();
6085
6086 let started = std::time::Instant::now();
6092 let timeout = local_decode_timeout();
6093 let heartbeat = std::time::Duration::from_secs(LOCAL_DECODE_HEARTBEAT_SECS);
6094 let mut last_heartbeat = std::time::Duration::ZERO;
6095 tracing::info!(
6096 prompt_tokens = tokens.len(),
6097 max_tokens = params.max_tokens,
6098 timeout_secs = timeout.map(|t| t.as_secs()),
6099 "local stream prefill starting"
6100 );
6101
6102 let logits = match Self::catch_mlx("stream prefill", || backend.forward(&tokens, 0)) {
6107 Ok(v) => v,
6108 Err(e) => {
6109 cache.invalidate(&model_id);
6110 return Err(e);
6111 }
6112 };
6113 let mut next_token = Self::sample_from_logits(&logits, params)?;
6114
6115 for _ in 0..params.max_tokens {
6116 if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
6117 break;
6118 }
6119
6120 generated.push(next_token);
6121 let delta = backend.decode(&[next_token])?;
6122 if !delta.is_empty()
6123 && tx
6124 .blocking_send(stream::StreamEvent::TextDelta(delta))
6125 .is_err()
6126 {
6127 return Ok(());
6128 }
6129
6130 if !params.stop.is_empty() {
6131 let text_so_far = backend.decode(&generated)?;
6132 if params.stop.iter().any(|s| text_so_far.contains(s)) {
6133 break;
6134 }
6135 }
6136
6137 let elapsed = started.elapsed();
6142 if deadline_exceeded(elapsed, timeout) {
6143 tracing::warn!(
6144 elapsed_secs = elapsed.as_secs_f64(),
6145 timeout_secs = timeout.map(|t| t.as_secs()),
6146 completion_tokens = generated.len(),
6147 max_tokens = params.max_tokens,
6148 "local stream hit its wall-clock ceiling and was cut short; \
6149 returning what was streamed so far. Raise or disable it with \
6150 CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables)."
6151 );
6152 let _ = tx.blocking_send(stream::StreamEvent::StopReason(
6157 LOCAL_DECODE_TIMEOUT_STOP_REASON.to_string(),
6158 ));
6159 break;
6160 }
6161 if heartbeat_due(elapsed, last_heartbeat, heartbeat) {
6162 last_heartbeat = elapsed;
6163 tracing::info!(
6164 completion_tokens = generated.len(),
6165 max_tokens = params.max_tokens,
6166 elapsed_secs = elapsed.as_secs_f64(),
6167 tokens_per_sec =
6168 generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON),
6169 "local stream in progress"
6170 );
6171 }
6172
6173 let pos = tokens.len() + generated.len() - 1;
6174 let logits =
6175 match Self::catch_mlx("stream forward", || backend.forward(&[next_token], pos)) {
6176 Ok(v) => v,
6177 Err(e) => {
6178 cache.invalidate(&model_id);
6179 return Err(e);
6180 }
6181 };
6182 next_token = Self::sample_from_logits(&logits, params)?;
6183 }
6184
6185 let trimmed = tasks::generate::truncate_at_stop(&backend.decode(&generated)?, ¶ms.stop);
6186 let text = tasks::generate::strip_thinking(&trimmed, params.thinking);
6187 let _ = tx.blocking_send(stream::StreamEvent::Usage {
6195 input_tokens: tokens.len() as u64,
6196 output_tokens: generated.len() as u64,
6197 cache_read_input_tokens: 0,
6199 cache_creation_input_tokens: 0,
6200 });
6201 let _ = tx.blocking_send(stream::StreamEvent::Done {
6202 text,
6203 tool_calls: vec![],
6204 });
6205 Ok(())
6206 }
6207
6208 pub async fn route_context_snapshot(
6210 &self,
6211 prompt: &str,
6212 workload: RoutingWorkload,
6213 has_tools: bool,
6214 has_vision: bool,
6215 ) -> AdaptiveRoutingDecision {
6216 let routing_registry = self.catalog_registry_snapshot();
6217 let tracker = self.outcome_tracker.read().await;
6218 self.adaptive_router.route_context_aware(
6219 prompt,
6220 0,
6221 &routing_registry,
6222 &tracker,
6223 has_tools,
6224 has_vision,
6225 workload,
6226 )
6227 }
6228
6229 pub async fn generate(&self, req: GenerateRequest) -> Result<String, InferenceError> {
6232 Ok(self.generate_tracked(req).await?.text)
6233 }
6234
6235 pub async fn tokenize(&self, model: &str, text: &str) -> Result<Vec<u32>, InferenceError> {
6247 self.assert_local_for_tokenize(model)?;
6248 let admission_schema = self
6249 .unified_registry
6250 .get(model)
6251 .or_else(|| self.unified_registry.find_by_name(model))
6252 .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?
6253 .clone();
6254 let mut reservation =
6256 self.reserve_local_request(&admission_schema, text.len().div_ceil(4))?;
6257
6258 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6259 {
6260 let schema = admission_schema;
6261 let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6262 let guard = handle.lock().map_err(|_| {
6263 InferenceError::InferenceFailed(format!(
6264 "MLX backend mutex poisoned for {}",
6265 schema.id
6266 ))
6267 })?;
6268 let tokens = guard.tokenize_raw(text)?;
6269 Ok(tokens)
6270 }
6271
6272 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6273 {
6274 self.ensure_backend(&admission_schema, &mut reservation)
6275 .await?;
6276 let read = self.backend.read().await;
6277 let backend = read.get(&admission_schema.id).ok_or_else(|| {
6278 InferenceError::InferenceFailed(
6279 "candle backend missing after ensure_backend".to_string(),
6280 )
6281 })?;
6282 let tokens = backend.tokenize_raw(text)?;
6283 Ok(tokens)
6284 }
6285 }
6286
6287 pub async fn detokenize(&self, model: &str, tokens: &[u32]) -> Result<String, InferenceError> {
6289 self.assert_local_for_tokenize(model)?;
6290 let admission_schema = self
6291 .unified_registry
6292 .get(model)
6293 .or_else(|| self.unified_registry.find_by_name(model))
6294 .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?
6295 .clone();
6296 let mut reservation = self.reserve_local_request(&admission_schema, tokens.len())?;
6298
6299 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6300 {
6301 let schema = admission_schema;
6302 let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6303 let guard = handle.lock().map_err(|_| {
6304 InferenceError::InferenceFailed(format!(
6305 "MLX backend mutex poisoned for {}",
6306 schema.id
6307 ))
6308 })?;
6309 let text = guard.detokenize_raw(tokens)?;
6310 Ok(text)
6311 }
6312
6313 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6314 {
6315 self.ensure_backend(&admission_schema, &mut reservation)
6316 .await?;
6317 let read = self.backend.read().await;
6318 let backend = read.get(&admission_schema.id).ok_or_else(|| {
6319 InferenceError::InferenceFailed(
6320 "candle backend missing after ensure_backend".to_string(),
6321 )
6322 })?;
6323 let text = backend.detokenize_raw(tokens)?;
6324 Ok(text)
6325 }
6326 }
6327
6328 fn assert_local_for_tokenize(&self, model: &str) -> Result<(), InferenceError> {
6332 if let Some(schema) = self
6333 .unified_registry
6334 .get(model)
6335 .or_else(|| self.unified_registry.find_by_name(model))
6336 {
6337 if !schema.is_local() {
6338 return Err(InferenceError::UnsupportedMode {
6339 mode: "tokenize/detokenize",
6340 backend: "remote",
6341 reason: "remote provider tokenizer is not exposed by the runtime; \
6342 use a local model (Qwen3 GGUF / MLX) for tokenizer-correctness checks",
6343 });
6344 }
6345 }
6346 Ok(())
6348 }
6349
6350 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6356 fn catch_mlx<F, T>(context: &str, f: F) -> Result<T, InferenceError>
6357 where
6358 F: FnOnce() -> Result<T, InferenceError>,
6359 {
6360 std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|e| {
6361 InferenceError::InferenceFailed(format!("MLX panicked during {context}: {e:?}"))
6362 })?
6363 }
6364
6365 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6375 fn drive_generation(
6376 backend: &mut dyn backend::local::TextDecoder,
6377 prompt: &str,
6378 params: &GenerateParams,
6379 ) -> Result<backend::local::LocalGeneration, backend::local::DriveError> {
6380 Self::drive_generation_with_timeout(backend, prompt, params, local_decode_timeout())
6381 }
6382
6383 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6387 fn drive_generation_with_timeout(
6388 backend: &mut dyn backend::local::TextDecoder,
6389 prompt: &str,
6390 params: &GenerateParams,
6391 timeout: Option<std::time::Duration>,
6392 ) -> Result<backend::local::LocalGeneration, backend::local::DriveError> {
6393 use backend::local::{DriveError, LocalGeneration};
6394
6395 let start = std::time::Instant::now();
6396
6397 let tokens = backend.encode(prompt).map_err(DriveError::Recoverable)?;
6398 let eos_ids = backend.eos_ids();
6399
6400 if tokens.is_empty() {
6401 backend.clear_kv_cache();
6402 return Ok(LocalGeneration {
6403 text: String::new(),
6404 ttft_ms: None,
6405 stop_reason: None,
6406 prompt_tokens: 0,
6407 completion_tokens: 0,
6408 });
6409 }
6410
6411 let max_ctx = backend.context_length();
6413 let headroom = params.max_tokens.min(max_ctx / 4);
6414 let max_prompt = max_ctx.saturating_sub(headroom);
6415 let tokens = if tokens.len() > max_prompt {
6416 tokens[tokens.len() - max_prompt..].to_vec()
6417 } else {
6418 tokens
6419 };
6420
6421 let mut generated = Vec::new();
6422
6423 tracing::info!(
6430 prompt_tokens = tokens.len(),
6431 max_tokens = params.max_tokens,
6432 timeout_secs = timeout.map(|t| t.as_secs()),
6433 "local prefill starting"
6434 );
6435
6436 let offset = backend.begin_prompt(&tokens);
6440 let logits = Self::catch_mlx("prefill", || backend.forward(&tokens[offset..], offset))
6441 .map_err(DriveError::BackendCorrupted)?;
6442 let mut next_token =
6443 Self::sample_from_logits(&logits, params).map_err(DriveError::Recoverable)?;
6444 let ttft_ms = Some(start.elapsed().as_millis() as u64);
6445
6446 tracing::info!(
6451 prompt_tokens = tokens.len(),
6452 max_tokens = params.max_tokens,
6453 prefill_ms = ttft_ms,
6454 timeout_secs = timeout.map(|t| t.as_secs()),
6455 "local decode starting"
6456 );
6457
6458 let mut natural_stop = false;
6461 let mut timed_out = false;
6462 let heartbeat = std::time::Duration::from_secs(LOCAL_DECODE_HEARTBEAT_SECS);
6463 let mut last_heartbeat = std::time::Duration::ZERO;
6464 for _ in 0..params.max_tokens {
6465 if eos_ids.contains(&next_token) {
6466 natural_stop = true;
6467 break;
6468 }
6469
6470 generated.push(next_token);
6471
6472 if !params.stop.is_empty() {
6473 let text_so_far = backend
6474 .decode(&generated)
6475 .map_err(DriveError::Recoverable)?;
6476 if params.stop.iter().any(|s| text_so_far.contains(s)) {
6477 natural_stop = true;
6478 break;
6479 }
6480 }
6481
6482 let elapsed = start.elapsed();
6483 if deadline_exceeded(elapsed, timeout) {
6484 timed_out = true;
6485 break;
6486 }
6487 if heartbeat_due(elapsed, last_heartbeat, heartbeat) {
6488 last_heartbeat = elapsed;
6489 tracing::info!(
6490 completion_tokens = generated.len(),
6491 max_tokens = params.max_tokens,
6492 elapsed_secs = elapsed.as_secs_f64(),
6493 tokens_per_sec =
6494 generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON),
6495 "local decode in progress"
6496 );
6497 }
6498
6499 let pos = tokens.len() + generated.len() - 1;
6500 let logits = Self::catch_mlx("forward", || backend.forward(&[next_token], pos))
6501 .map_err(DriveError::BackendCorrupted)?;
6502 next_token =
6503 Self::sample_from_logits(&logits, params).map_err(DriveError::Recoverable)?;
6504 }
6505
6506 let elapsed = start.elapsed();
6507 let tokens_per_sec = generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON);
6508 if timed_out {
6509 tracing::warn!(
6510 elapsed_secs = elapsed.as_secs_f64(),
6511 timeout_secs = timeout.map(|t| t.as_secs()),
6512 completion_tokens = generated.len(),
6513 max_tokens = params.max_tokens,
6514 tokens_per_sec,
6515 "local decode hit its wall-clock ceiling and was cut short; \
6516 returning the partial response. Raise or disable it with \
6517 CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables)."
6518 );
6519 } else {
6520 tracing::info!(
6521 elapsed_secs = elapsed.as_secs_f64(),
6522 completion_tokens = generated.len(),
6523 max_tokens = params.max_tokens,
6524 tokens_per_sec,
6525 natural_stop,
6526 "local decode finished"
6527 );
6528 }
6529
6530 let decoded = backend
6531 .decode(&generated)
6532 .map_err(DriveError::Recoverable)?;
6533 let text = tasks::generate::truncate_at_stop(&decoded, ¶ms.stop);
6534 let stop_reason = Some(
6535 if timed_out {
6536 LOCAL_DECODE_TIMEOUT_STOP_REASON
6537 } else if natural_stop {
6538 "stop"
6539 } else {
6540 "length"
6541 }
6542 .to_string(),
6543 );
6544 Ok(LocalGeneration {
6545 text: tasks::generate::strip_thinking(&text, params.thinking),
6546 ttft_ms,
6547 stop_reason,
6548 prompt_tokens: tokens.len(),
6549 completion_tokens: generated.len(),
6550 })
6551 }
6552
6553 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6556 async fn generate_mlx(
6566 &self,
6567 req: GenerateRequest,
6568 model_id: &str,
6569 reservation: &mut resource_policy::LocalLoadReservation,
6570 ) -> Result<(String, Option<TokenUsage>, Option<u64>, Option<String>), InferenceError> {
6571 let schema = self
6572 .unified_registry
6573 .get(model_id)
6574 .cloned()
6575 .ok_or_else(|| {
6576 InferenceError::InferenceFailed(format!(
6577 "generate_mlx: unknown schema id {model_id}"
6578 ))
6579 })?;
6580 let (handle, _retention) = self.ensure_mlx_backend(&schema, reservation).await?;
6581 let formatted = tasks::generate::render_chat_prompt(&req);
6588 let params = req.params.clone();
6589 let model_id = model_id.to_string();
6590 let cache = self.mlx_backends.clone();
6591 let device_guard = Self::mlx_device_lock().lock_owned().await;
6592 let detached_lease = reservation.detached_lease();
6593
6594 run_admitted_blocking(detached_lease, move || {
6600 let _device_guard = device_guard;
6601 let mut guard = handle.lock().map_err(|_| {
6602 InferenceError::InferenceFailed(format!(
6603 "MLX backend mutex poisoned for {model_id}"
6604 ))
6605 })?;
6606 let backend: &mut backend::MlxBackend = &mut guard;
6607 let ctx_window = backend.context_length() as u64;
6608 match Self::drive_generation(backend, &formatted, ¶ms) {
6609 Ok(gen) => {
6610 let usage = TokenUsage {
6611 prompt_tokens: gen.prompt_tokens as u64,
6612 completion_tokens: gen.completion_tokens as u64,
6613 total_tokens: (gen.prompt_tokens + gen.completion_tokens) as u64,
6614 context_window: ctx_window,
6615 ..Default::default()
6616 };
6617 Ok((gen.text, Some(usage), gen.ttft_ms, gen.stop_reason))
6618 }
6619 Err(backend::local::DriveError::Recoverable(error)) => Err(error),
6620 Err(backend::local::DriveError::BackendCorrupted(error)) => {
6621 drop(guard);
6622 cache.invalidate(&model_id);
6623 Err(error)
6624 }
6625 }
6626 })
6627 .await
6628 .map_err(|error| {
6629 InferenceError::InferenceFailed(format!("native MLX task panicked: {error}"))
6630 })?
6631 }
6632
6633 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6638 async fn generate_local(
6639 &self,
6640 req: GenerateRequest,
6641 model_id: &str,
6642 reservation: &mut resource_policy::LocalLoadReservation,
6643 ) -> Result<
6644 (
6645 String,
6646 Vec<tasks::generate::ToolCall>,
6647 Option<TokenUsage>,
6648 Option<u64>,
6649 Option<String>,
6650 ),
6651 InferenceError,
6652 > {
6653 let schema = self
6654 .unified_registry
6655 .get(model_id)
6656 .cloned()
6657 .ok_or_else(|| {
6658 InferenceError::InferenceFailed(format!(
6659 "generate_local: unknown schema id {model_id}"
6660 ))
6661 })?;
6662 let (handle, _retention) = self.ensure_local_backend(&schema, reservation).await?;
6663 let params = req.params.clone();
6664 let model_id = model_id.to_string();
6665 let cache = self.local_backends.clone();
6666 let device_guard = Self::mlx_device_lock().lock_owned().await;
6667 let detached_lease = reservation.detached_lease();
6668
6669 run_admitted_blocking(detached_lease, move || {
6670 let _device_guard = device_guard;
6671 let mut guard = handle.lock().map_err(|_| {
6672 InferenceError::InferenceFailed(format!(
6673 "local backend mutex poisoned for {model_id}"
6674 ))
6675 })?;
6676 let formatted = guard.render_prompt(&req)?;
6677 let backend: &mut dyn backend::local::TextDecoder = &mut **guard;
6678 let outcome = Self::drive_generation(backend, &formatted, ¶ms);
6679 match outcome {
6680 Ok(gen) => {
6681 let (clean, tool_calls) = guard.parse_tool_calls(&gen.text);
6682 let usage = TokenUsage {
6683 prompt_tokens: gen.prompt_tokens as u64,
6684 completion_tokens: gen.completion_tokens as u64,
6685 total_tokens: (gen.prompt_tokens + gen.completion_tokens) as u64,
6686 context_window: guard.context_length() as u64,
6687 ..Default::default()
6688 };
6689 Ok((clean, tool_calls, Some(usage), gen.ttft_ms, gen.stop_reason))
6690 }
6691 Err(backend::local::DriveError::Recoverable(error)) => Err(error),
6692 Err(backend::local::DriveError::BackendCorrupted(error)) => {
6693 drop(guard);
6694 cache.invalidate(&model_id);
6695 Err(error)
6696 }
6697 }
6698 })
6699 .await
6700 .map_err(|error| {
6701 InferenceError::InferenceFailed(format!("native local task panicked: {error}"))
6702 })?
6703 }
6704
6705 #[allow(dead_code)] fn apply_top_k_top_p(probs: &mut [f32], top_k: usize, top_p: f64) {
6713 if top_k > 0 && top_k < probs.len() {
6715 let mut indexed: Vec<(usize, f32)> = probs.iter().copied().enumerate().collect();
6716 indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6717 let allowed: std::collections::HashSet<usize> =
6718 indexed[..top_k].iter().map(|(i, _)| *i).collect();
6719 for (i, p) in probs.iter_mut().enumerate() {
6720 if !allowed.contains(&i) {
6721 *p = 0.0;
6722 }
6723 }
6724 let sum: f32 = probs.iter().sum();
6725 if sum > 0.0 {
6726 for p in probs.iter_mut() {
6727 *p /= sum;
6728 }
6729 }
6730 }
6731
6732 if top_p < 1.0 {
6734 let mut indexed: Vec<(usize, f32)> = probs.iter().copied().enumerate().collect();
6735 indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6736 let mut cumsum = 0.0f32;
6737 let mut cutoff_idx = indexed.len();
6738 for (i, &(_, p)) in indexed.iter().enumerate() {
6739 cumsum += p;
6740 if cumsum > top_p as f32 {
6741 cutoff_idx = i + 1;
6742 break;
6743 }
6744 }
6745 let allowed: std::collections::HashSet<usize> =
6746 indexed[..cutoff_idx].iter().map(|(i, _)| *i).collect();
6747 for (i, p) in probs.iter_mut().enumerate() {
6748 if !allowed.contains(&i) {
6749 *p = 0.0;
6750 }
6751 }
6752 let sum: f32 = probs.iter().sum();
6753 if sum > 0.0 {
6754 for p in probs.iter_mut() {
6755 *p /= sum;
6756 }
6757 }
6758 }
6759 }
6760
6761 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6763 fn sample_from_logits(logits: &[f32], params: &GenerateParams) -> Result<u32, InferenceError> {
6764 if params.temperature <= 0.0 {
6765 let (idx, _) = logits
6767 .iter()
6768 .enumerate()
6769 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
6770 .ok_or_else(|| InferenceError::InferenceFailed("empty logits".into()))?;
6771 return Ok(idx as u32);
6772 }
6773
6774 let temp = params.temperature as f32;
6776 let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
6777 let mut probs: Vec<f32> = logits
6778 .iter()
6779 .map(|&l| ((l - max_logit) / temp).exp())
6780 .collect();
6781 let sum: f32 = probs.iter().sum();
6782 for p in &mut probs {
6783 *p /= sum;
6784 }
6785
6786 Self::apply_top_k_top_p(&mut probs, params.top_k, params.top_p);
6792
6793 use rand::Rng;
6795 let mut rng = rand::rng();
6796 let r: f32 = rng.random();
6797 let mut cumsum = 0.0;
6798 for (i, &p) in probs.iter().enumerate() {
6799 cumsum += p;
6800 if cumsum >= r {
6801 return Ok(i as u32);
6802 }
6803 }
6804 Ok((probs.len() - 1) as u32)
6805 }
6806
6807 pub async fn embed(&self, req: EmbedRequest) -> Result<Vec<Vec<f32>>, InferenceError> {
6810 let instruction = req
6811 .instruction
6812 .as_deref()
6813 .unwrap_or("Retrieve relevant memory facts");
6814 let embedding_model = self
6815 .preferred_model_for_capability(ModelCapability::Embed)
6816 .unwrap_or(&self.config.embedding_model);
6817 let admission_schema = self
6818 .unified_registry
6819 .get(embedding_model)
6820 .or_else(|| self.unified_registry.find_by_name(embedding_model))
6821 .ok_or_else(|| InferenceError::ModelNotFound(embedding_model.to_string()))?
6822 .clone();
6823 let estimated_tokens = req.texts.iter().map(|text| text.len().div_ceil(4)).sum();
6824 let mut reservation = self.reserve_local_request(&admission_schema, estimated_tokens)?;
6826
6827 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6828 {
6829 let model_id = self.ensure_mlx_embedding_backend().await?;
6830 let schema = self
6831 .unified_registry
6832 .get(&model_id)
6833 .cloned()
6834 .ok_or_else(|| {
6835 InferenceError::InferenceFailed(format!("embed: unknown schema id {model_id}"))
6836 })?;
6837 let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6838 let _device_guard = Self::mlx_device_lock().lock_owned().await;
6842 let mut guard = handle.lock().map_err(|_| {
6843 InferenceError::InferenceFailed(format!(
6844 "MLX embedding backend mutex poisoned for {model_id}"
6845 ))
6846 })?;
6847 let backend: &mut backend::MlxBackend = &mut guard;
6848
6849 let mut results = Vec::with_capacity(req.texts.len());
6850 for text in &req.texts {
6851 let embedding = if req.is_query {
6852 backend.embed_query(text, instruction)?
6853 } else {
6854 backend.embed_one(text)?
6855 };
6856 results.push(embedding);
6857 }
6858 Ok(results)
6859 }
6860
6861 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6862 {
6863 self.ensure_embedding_backend(&mut reservation).await?;
6864 let mut write = self.embedding_backend.write().await;
6865 let backend = write.as_mut().unwrap();
6866
6867 let mut results = Vec::with_capacity(req.texts.len());
6868 for text in &req.texts {
6869 let embedding = if req.is_query {
6870 backend.embed_query(text, instruction)?
6871 } else {
6872 backend.embed_one(text)?
6873 };
6874 results.push(embedding);
6875 }
6876 Ok(results)
6877 }
6878 }
6879
6880 pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResult, InferenceError> {
6910 if req.documents.is_empty() {
6911 return Ok(RerankResult {
6912 ranked: Vec::new(),
6913 model_used: None,
6914 });
6915 }
6916
6917 let model_name = match req.model.clone() {
6918 Some(m) => m,
6919 None => self
6920 .preferred_model_for_capability(ModelCapability::Rerank)
6921 .map(str::to_string)
6922 .ok_or_else(|| {
6923 InferenceError::InferenceFailed(
6924 "no reranker model available — pull a Qwen3-Reranker model first".into(),
6925 )
6926 })?,
6927 };
6928
6929 let schema = self
6930 .unified_registry
6931 .find_by_name(&model_name)
6932 .or_else(|| self.unified_registry.get(&model_name))
6933 .cloned()
6934 .ok_or_else(|| {
6935 InferenceError::InferenceFailed(format!(
6936 "rerank: unknown reranker model {model_name}"
6937 ))
6938 })?;
6939 if !schema.has_capability(ModelCapability::Rerank) {
6940 return Err(InferenceError::InferenceFailed(format!(
6941 "model {} does not declare the Rerank capability",
6942 schema.name
6943 )));
6944 }
6945
6946 let instruction = req.instruction.as_deref().unwrap_or(
6947 "Given a web search query, retrieve relevant passages that answer the query",
6948 );
6949
6950 let mut scored: Vec<RerankedDocument> = Vec::with_capacity(req.documents.len());
6951 for (idx, doc) in req.documents.iter().enumerate() {
6952 let prompt = rerank_prompt(instruction, &req.query, doc);
6953 let gen_req = GenerateRequest {
6954 prompt,
6955 model: Some(schema.id.clone()),
6956 params: tasks::generate::GenerateParams {
6957 temperature: 0.0,
6958 max_tokens: 3,
6962 thinking: tasks::generate::ThinkingMode::Off,
6963 ..Default::default()
6964 },
6965 context: None,
6966 context_stable_prefix: None,
6967 tools: None,
6968 images: None,
6969 messages: None,
6970 cache_control: false,
6971 response_format: None,
6972 intent: None,
6973 client_ref: None,
6974 expected_row_digest: None,
6975 expected_catalog_revision: None,
6976 caller: None,
6977 };
6978 let out = self.generate(gen_req).await?;
6979 let score = score_from_rerank_output(&out, &schema.name);
6980 scored.push(RerankedDocument {
6981 index: idx,
6982 score,
6983 document: doc.clone(),
6984 });
6985 }
6986
6987 scored.sort_by(|a, b| {
6990 b.score
6991 .partial_cmp(&a.score)
6992 .unwrap_or(std::cmp::Ordering::Equal)
6993 .then_with(|| a.index.cmp(&b.index))
6994 });
6995 if let Some(n) = req.top_n {
6996 scored.truncate(n);
6997 }
6998
6999 Ok(RerankResult {
7000 ranked: scored,
7001 model_used: Some(schema.name),
7002 })
7003 }
7004
7005 pub async fn ground(&self, req: GroundRequest) -> Result<GroundResult, InferenceError> {
7015 let model_name = match req.model.clone() {
7016 Some(m) => m,
7017 None => self
7018 .preferred_model_for_capability(ModelCapability::Grounding)
7019 .map(str::to_string)
7020 .ok_or_else(|| {
7021 InferenceError::InferenceFailed(
7022 "no grounding-capable model available — pull a Qwen2.5-VL model first"
7023 .into(),
7024 )
7025 })?,
7026 };
7027
7028 let gen_req = GenerateRequest {
7029 prompt: req.prompt.clone(),
7030 model: Some(model_name),
7031 params: GenerateParams::default(),
7032 context: None,
7033 context_stable_prefix: None,
7034 tools: None,
7035 images: Some(vec![req.image.clone()]),
7036 messages: None,
7037 cache_control: false,
7038 response_format: None,
7039 intent: None,
7040 client_ref: None,
7041 expected_row_digest: None,
7042 expected_catalog_revision: None,
7043 caller: None,
7044 };
7045 let result = self.generate_tracked(gen_req).await?;
7046 Ok(GroundResult {
7047 boxes: result.bounding_boxes,
7048 raw_text: result.text,
7049 model_used: Some(result.model_used),
7050 })
7051 }
7052
7053 pub async fn classify(
7056 &self,
7057 req: ClassifyRequest,
7058 ) -> Result<Vec<ClassifyResult>, InferenceError> {
7059 let model = match req.model.clone().or_else(|| {
7060 self.preferred_model_for_capability(ModelCapability::Classify)
7061 .map(str::to_string)
7062 }) {
7063 Some(m) => m,
7064 None => {
7065 let m = self.router.route_small(&self.registry);
7066 debug!(model = %m, "auto-routed classify request");
7067 m
7068 }
7069 };
7070 let schema = self
7071 .unified_registry
7072 .get(&model)
7073 .or_else(|| self.unified_registry.find_by_name(&model))
7074 .ok_or_else(|| InferenceError::ModelNotFound(model.clone()))?
7075 .clone();
7076
7077 if !schema.is_local() {
7078 return self.classify_via_generate(req, &schema.id).await;
7079 }
7080
7081 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7084 {
7085 return self.classify_via_generate(req, &model).await;
7086 }
7087
7088 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7089 {
7090 let mut reservation =
7092 self.reserve_local_request(&schema, req.text.len().div_ceil(4))?;
7093 self.ensure_backend(&schema, &mut reservation).await?;
7094 let mut write = self.backend.write().await;
7095 let backend = write.get_mut(&schema.id).ok_or_else(|| {
7096 InferenceError::InferenceFailed(format!(
7097 "candle backend missing after ensure_backend for {}",
7098 schema.id
7099 ))
7100 })?;
7101 let result = tasks::classify::classify(backend, req).await?;
7102 Ok(result)
7103 }
7104 }
7105
7106 async fn classify_via_generate(
7109 &self,
7110 req: ClassifyRequest,
7111 model: &str,
7112 ) -> Result<Vec<ClassifyResult>, InferenceError> {
7113 let labels_str = req
7114 .labels
7115 .iter()
7116 .enumerate()
7117 .map(|(i, l)| format!("{}. {}", i + 1, l))
7118 .collect::<Vec<_>>()
7119 .join("\n");
7120
7121 let prompt = format!(
7122 "Classify the following text into one of these categories:\n\
7123 {labels_str}\n\n\
7124 Text: {}\n\n\
7125 Respond with ONLY the category name, nothing else.",
7126 req.text
7127 );
7128
7129 let gen_req = GenerateRequest {
7130 prompt,
7131 model: Some(model.to_string()),
7132 params: tasks::generate::GenerateParams {
7133 temperature: 0.0,
7134 max_tokens: 32,
7135 thinking: tasks::generate::ThinkingMode::Off,
7138 ..Default::default()
7139 },
7140 context: None,
7141 context_stable_prefix: None,
7142 tools: None,
7143 images: None,
7144 messages: None,
7145 cache_control: false,
7146 response_format: None,
7147 intent: None,
7148 client_ref: None,
7149 expected_row_digest: None,
7150 expected_catalog_revision: None,
7151 caller: None,
7152 };
7153
7154 let response = self.generate(gen_req).await?;
7155 let response_lower = response.trim().to_lowercase();
7156
7157 let mut results: Vec<ClassifyResult> = req
7158 .labels
7159 .iter()
7160 .map(|label| {
7161 let label_lower = label.to_lowercase();
7162 let score = if response_lower == label_lower {
7163 1.0
7164 } else if response_lower.contains(&label_lower) {
7165 0.8
7166 } else {
7167 let label_words: Vec<&str> = label_lower.split_whitespace().collect();
7168 let matches = label_words
7169 .iter()
7170 .filter(|w| response_lower.contains(**w))
7171 .count();
7172 if label_words.is_empty() {
7173 0.0
7174 } else {
7175 0.5 * (matches as f64 / label_words.len() as f64)
7176 }
7177 };
7178 ClassifyResult {
7179 label: label.clone(),
7180 score,
7181 }
7182 })
7183 .collect();
7184
7185 results.sort_by(|a, b| {
7186 b.score
7187 .partial_cmp(&a.score)
7188 .unwrap_or(std::cmp::Ordering::Equal)
7189 });
7190
7191 let total: f64 = results.iter().map(|r| r.score).sum();
7192 if total > 0.0 {
7193 for r in &mut results {
7194 r.score /= total;
7195 }
7196 }
7197
7198 Ok(results)
7199 }
7200
7201 pub async fn transcribe(
7203 &self,
7204 req: TranscribeRequest,
7205 ) -> Result<TranscribeResult, InferenceError> {
7206 let candidates =
7207 self.speech_candidates(ModelCapability::SpeechToText, req.model.as_deref())?;
7208 let mut last_error = None;
7209 let mut local_resource_block = None;
7210
7211 for schema in candidates {
7212 let mut reservation = match self.admit_speech_candidate(&schema, req.model.is_some()) {
7214 SpeechCandidateAdmission::Proceed(reservation) => reservation,
7215 SpeechCandidateAdmission::SkipBlocked(error) => {
7216 local_resource_block = Some(error.to_string());
7217 last_error = Some(error);
7218 continue;
7219 }
7220 SpeechCandidateAdmission::FailBlocked(error) => return Err(error),
7221 };
7222 let result = match &schema.source {
7223 ModelSource::Mlx { .. } => {
7224 self.transcribe_local_mlx(&schema, &req, reservation.as_mut())
7225 .await
7226 }
7227 ModelSource::WhisperCpp { model } => {
7228 self.transcribe_whisper(&schema, model, &req, reservation.as_mut())
7229 .await
7230 }
7231 ModelSource::Proprietary { provider, .. } if provider == "elevenlabs" => {
7232 self.transcribe_elevenlabs(&schema, &req).await
7233 }
7234 _ => Err(InferenceError::InferenceFailed(format!(
7235 "speech-to-text not implemented for model source: {}",
7236 schema.id
7237 ))),
7238 };
7239
7240 match result {
7241 Ok(mut result) => {
7242 if matches!(schema.source, ModelSource::Proprietary { .. }) {
7243 if let Some(reason) = local_resource_block.take() {
7244 result.routing_explanation = Some(format!(
7245 "Local speech recognition was blocked ({reason}); CAR routed to a remote provider, which may affect privacy and cost."
7246 ));
7247 }
7248 }
7249 return Ok(result);
7250 }
7251 Err(err) if req.model.is_some() => return Err(err),
7252 Err(err) => last_error = Some(err),
7253 }
7254 }
7255
7256 Err(last_error.unwrap_or_else(|| {
7257 InferenceError::InferenceFailed("no speech-to-text models available".into())
7258 }))
7259 }
7260
7261 pub async fn synthesize(
7263 &self,
7264 req: SynthesizeRequest,
7265 ) -> Result<SynthesizeResult, InferenceError> {
7266 let candidates =
7267 self.speech_candidates(ModelCapability::TextToSpeech, req.model.as_deref())?;
7268 let mut last_error = None;
7269 let mut local_resource_block = None;
7270
7271 for schema in candidates {
7272 let mut reservation = match self.admit_speech_candidate(&schema, req.model.is_some()) {
7274 SpeechCandidateAdmission::Proceed(reservation) => reservation,
7275 SpeechCandidateAdmission::SkipBlocked(error) => {
7276 local_resource_block = Some(error.to_string());
7277 last_error = Some(error);
7278 continue;
7279 }
7280 SpeechCandidateAdmission::FailBlocked(error) => return Err(error),
7281 };
7282 let result = match &schema.source {
7283 ModelSource::Mlx { .. } => {
7284 self.synthesize_local_mlx(&schema, &req, reservation.as_mut())
7285 .await
7286 }
7287 ModelSource::WindowsSpeech {} => {
7288 self.synthesize_windows_speech(&schema, &req).await
7289 }
7290 ModelSource::Proprietary { provider, .. } if provider == "elevenlabs" => {
7291 self.synthesize_elevenlabs(&schema, &req).await
7292 }
7293 _ => Err(InferenceError::InferenceFailed(format!(
7294 "text-to-speech not implemented for model source: {}",
7295 schema.id
7296 ))),
7297 };
7298
7299 match result {
7300 Ok(mut result) => {
7301 if matches!(schema.source, ModelSource::Proprietary { .. }) {
7302 if let Some(reason) = local_resource_block.take() {
7303 result.routing_explanation = Some(format!(
7304 "Local speech synthesis was blocked ({reason}); CAR routed to a remote provider, which may affect privacy and cost."
7305 ));
7306 }
7307 }
7308 return Ok(result);
7309 }
7310 Err(err) if req.model.is_some() => return Err(err),
7311 Err(err) => last_error = Some(err),
7312 }
7313 }
7314
7315 Err(last_error.unwrap_or_else(|| {
7316 InferenceError::InferenceFailed("no text-to-speech models available".into())
7317 }))
7318 }
7319
7320 async fn synthesize_windows_speech(
7326 &self,
7327 schema: &ModelSchema,
7328 req: &SynthesizeRequest,
7329 ) -> Result<SynthesizeResult, InferenceError> {
7330 #[cfg(target_os = "windows")]
7331 {
7332 let text = req.text.clone();
7333 let voice = req.voice.clone().unwrap_or_default();
7334 let rate = req.speed.unwrap_or(1.0) as f64;
7335 let bytes =
7336 tokio::task::spawn_blocking(move || winrt_synthesize_wav(&text, &voice, rate))
7337 .await
7338 .map_err(|e| {
7339 InferenceError::InferenceFailed(format!("winrt tts join: {e}"))
7340 })??;
7341 let dest = requested_or_temp_output(req.output_path.as_deref(), "wav")?;
7342 ensure_parent_dir(&dest)?;
7343 std::fs::write(&dest, &bytes)?;
7344 Ok(SynthesizeResult {
7345 audio_path: dest.to_string_lossy().to_string(),
7346 media_type: "audio/wav".to_string(),
7347 model_used: Some(schema.name.clone()),
7348 voice_used: req.voice.clone(),
7349 routing_explanation: None,
7350 })
7351 }
7352 #[cfg(not(target_os = "windows"))]
7353 {
7354 let _ = (schema, req);
7355 Err(InferenceError::InferenceFailed(
7356 "Windows OS TTS is only available on Windows".into(),
7357 ))
7358 }
7359 }
7360
7361 pub async fn generate_image(
7363 &self,
7364 req: GenerateImageRequest,
7365 ) -> Result<GenerateImageResult, InferenceError> {
7366 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7382 {
7383 use crate::backend::external_flux;
7384 let use_external = !external_flux::native_backend_serves(req.model.as_deref());
7385 if use_external {
7386 tracing::info!(
7387 model = req.model.as_deref().unwrap_or("<default>"),
7388 "no native Rust implementation for this image architecture; routing to mflux"
7389 );
7390 let schema = self
7391 .media_generation_candidates(
7392 ModelCapability::ImageGeneration,
7393 req.model.as_deref(),
7394 )?
7395 .into_iter()
7396 .next()
7397 .ok_or_else(|| {
7398 InferenceError::InferenceFailed(
7399 "no image generation models available".into(),
7400 )
7401 })?;
7402 let mut reservation = self.reserve_local_request(&schema, 0)?;
7404 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7405 reservation
7406 .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
7407 .map_err(InferenceError::from)?;
7408 let mut req = req;
7409 req.model = self.resolve_external_hf_repo(
7410 req.model.as_deref(),
7411 ModelCapability::ImageGeneration,
7412 );
7413 return external_flux::generate_image(&req);
7414 }
7415 tracing::info!("using native Rust MLX Flux backend");
7416 }
7417
7418 let candidates = self
7419 .media_generation_candidates(ModelCapability::ImageGeneration, req.model.as_deref())?;
7420 let mut last_error = None;
7421
7422 for schema in candidates {
7423 #[cfg_attr(
7425 not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
7426 allow(unused_mut, unused_variables)
7427 )]
7428 let mut reservation = self.reserve_local_request(&schema, 0)?;
7429 let result = match &schema.source {
7430 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7431 ModelSource::Mlx { .. } => {
7432 self.generate_image_native_mlx(&schema, &req, &mut reservation)
7433 .await
7434 }
7435 _ => Err(InferenceError::InferenceFailed(format!(
7436 "image generation not implemented for model source: {}",
7437 schema.id
7438 ))),
7439 };
7440
7441 match result {
7442 Ok(result) => return Ok(result),
7443 Err(err) if req.model.is_some() => return Err(err),
7444 Err(err) => last_error = Some(err),
7445 }
7446 }
7447
7448 Err(last_error.unwrap_or_else(|| {
7449 InferenceError::InferenceFailed("no image generation models available".into())
7450 }))
7451 }
7452
7453 pub async fn generate_image_batch(
7469 &self,
7470 req: GenerateImageRequest,
7471 ) -> Result<Vec<GenerateImageResult>, InferenceError> {
7472 let count = req.variant_count.unwrap_or(1).max(1);
7473 if count == 1 {
7474 return self.generate_image(req).await.map(|r| vec![r]);
7475 }
7476 let base_seed = req.seed.unwrap_or(0);
7477 let mut results = Vec::with_capacity(count as usize);
7478 for i in 0..count {
7479 let mut variant_req = req.clone();
7484 variant_req.seed = Some(base_seed.wrapping_add(i as u64));
7485 variant_req.variant_count = Some(1);
7489 results.push(self.generate_image(variant_req).await?);
7490 }
7491 Ok(results)
7492 }
7493
7494 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7506 fn mlx_device_lock() -> Arc<tokio::sync::Mutex<()>> {
7507 static MLX_DEVICE_LOCK: std::sync::OnceLock<Arc<tokio::sync::Mutex<()>>> =
7508 std::sync::OnceLock::new();
7509 MLX_DEVICE_LOCK
7510 .get_or_init(|| Arc::new(tokio::sync::Mutex::new(())))
7511 .clone()
7512 }
7513
7514 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7516 async fn generate_image_native_mlx(
7517 &self,
7518 schema: &ModelSchema,
7519 req: &GenerateImageRequest,
7520 reservation: &mut resource_policy::LocalLoadReservation,
7521 ) -> Result<GenerateImageResult, InferenceError> {
7522 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7523 let size = backend_cache::estimate_model_size(&model_dir);
7524 let (handle, _retention) = Self::load_backend_healing(
7526 &schema.id,
7527 model_dir,
7528 &self.flux_cache,
7529 size,
7530 reservation,
7531 backend::mlx_flux::FluxBackend::load,
7532 || self.unified_registry.redownload_local(&schema.id),
7533 )
7534 .await?;
7535 let req = req.clone();
7540 let device_guard = Self::mlx_device_lock().lock_owned().await;
7541 tokio::task::spawn_blocking(move || -> Result<GenerateImageResult, InferenceError> {
7542 let _device_guard = device_guard;
7544 let mut guard = handle.lock().map_err(|_| {
7545 InferenceError::InferenceFailed("flux backend mutex poisoned".into())
7546 })?;
7547 guard.generate(&req)
7548 })
7549 .await
7550 .map_err(|e| InferenceError::InferenceFailed(format!("flux task join: {e}")))?
7551 }
7552
7553 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7555 async fn generate_video_native_mlx(
7556 &self,
7557 schema: &ModelSchema,
7558 req: &GenerateVideoRequest,
7559 reservation: &mut resource_policy::LocalLoadReservation,
7560 ) -> Result<GenerateVideoResult, InferenceError> {
7561 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7562 let size = backend_cache::estimate_model_size(&model_dir);
7563 let (handle, _retention) = Self::load_backend_healing(
7565 &schema.id,
7566 model_dir,
7567 &self.ltx_cache,
7568 size,
7569 reservation,
7570 backend::mlx_ltx::LtxBackend::load,
7571 || self.unified_registry.redownload_local(&schema.id),
7572 )
7573 .await?;
7574 let req = req.clone();
7575
7576 let device_guard = Self::mlx_device_lock().lock_owned().await;
7607
7608 tokio::task::spawn_blocking(move || -> Result<GenerateVideoResult, InferenceError> {
7609 let _device_guard = device_guard;
7612 let mut guard = handle.lock().map_err(|_| {
7613 InferenceError::InferenceFailed("ltx backend mutex poisoned".into())
7614 })?;
7615 guard.generate(&req)
7616 })
7617 .await
7618 .map_err(|e| InferenceError::InferenceFailed(format!("ltx task join: {e}")))?
7619 }
7620
7621 pub async fn generate_video(
7623 &self,
7624 req: GenerateVideoRequest,
7625 ) -> Result<GenerateVideoResult, InferenceError> {
7626 if let Err(msg) = req.validate() {
7629 return Err(InferenceError::InferenceFailed(format!(
7630 "invalid GenerateVideoRequest: {}",
7631 msg
7632 )));
7633 }
7634 #[allow(unused_variables)]
7636 let requires_audio_conditioning = req.requires_audio_passthrough_opt_in();
7637 let candidates = self
7640 .media_generation_candidates(ModelCapability::VideoGeneration, req.model.as_deref())?;
7641 let mut last_error = None;
7642
7643 for schema in candidates {
7644 let mut reservation = self.reserve_local_request(&schema, 0)?;
7646 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7647 reservation
7648 .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
7649 .map_err(InferenceError::from)?;
7650 let result = match &schema.source {
7651 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7652 ModelSource::Mlx { hf_repo, .. } => {
7653 let use_external_ltx = requires_audio_conditioning;
7667 if requires_audio_conditioning && !crate::backend::external_ltx::is_available()
7668 {
7669 return Err(InferenceError::InferenceFailed(
7670 "audio-reference video conditioning requires the external `ltx-2-mlx a2v` CLI on PATH"
7671 .to_string(),
7672 ));
7673 }
7674 if use_external_ltx {
7675 let mut req = req.clone();
7676 req.model = Some(hf_repo.clone());
7677 crate::backend::external_ltx::generate_video(&req)
7678 } else {
7679 self.generate_video_native_mlx(&schema, &req, &mut reservation)
7680 .await
7681 }
7682 }
7683 _ => Err(InferenceError::InferenceFailed(format!(
7684 "video generation not implemented for model source: {}",
7685 schema.id
7686 ))),
7687 };
7688
7689 match result {
7690 Ok(result) => return Ok(result),
7691 Err(err) if req.model.is_some() => return Err(err),
7692 Err(err) => last_error = Some(err),
7693 }
7694 }
7695
7696 Err(last_error.unwrap_or_else(|| {
7697 InferenceError::InferenceFailed("no video generation models available".into())
7698 }))
7699 }
7700
7701 pub fn response_format_rejection_reason(
7709 &self,
7710 model: &str,
7711 rf: &crate::tasks::generate::ResponseFormat,
7712 ) -> Option<String> {
7713 let registry = self.unified_registry.clone();
7714 let schema = registry
7715 .list()
7716 .into_iter()
7717 .find(|s| s.id == model || s.name == model)?;
7718 match &schema.source {
7719 ModelSource::Proprietary { .. } => {
7720 Some("the Parslee gateway does not accept response_format".to_string())
7721 }
7722 ModelSource::RemoteApi { protocol, .. } => {
7723 let handler = crate::protocol::handler_for(*protocol);
7724 (!handler.supports_response_format(rf)).then(|| {
7725 format!(
7726 "the {} protocol rejects response_format",
7727 handler.protocol_name()
7728 )
7729 })
7730 }
7731 _ => None,
7732 }
7733 }
7734
7735 pub fn list_models_unified(&self) -> Vec<ModelInfo> {
7740 let hardware = HardwareInfo::detect();
7741 let policy = self.active_local_resource_policy();
7742 self.list_models_unified_for(&hardware, &policy.policy)
7743 }
7744
7745 pub fn model_fit(&self, schema: &ModelSchema) -> recommend::ModelFit {
7749 recommend::model_fit(
7750 schema,
7751 &HardwareInfo::detect(),
7752 Some(&self.active_local_resource_policy().policy),
7753 )
7754 }
7755
7756 pub fn list_models_unified_for(
7760 &self,
7761 hardware: &HardwareInfo,
7762 policy: &resource_policy::ResourcePolicy,
7763 ) -> Vec<ModelInfo> {
7764 let mut registry = self.unified_registry.clone();
7765 registry.refresh_availability();
7766 registry
7767 .list()
7768 .iter()
7769 .map(|schema| {
7770 let mut info = ModelInfo::from(*schema).with_fit(recommend::model_fit(
7771 schema,
7772 hardware,
7773 Some(policy),
7774 ));
7775 if schema.downloads_weights() {
7776 let enabled = self
7777 .model_management
7778 .car_enabled(&schema.id)
7779 .unwrap_or(false);
7780 if !enabled {
7781 info.available = false;
7785 }
7786 if enabled
7787 && self
7788 .model_management
7789 .load_receipt(&schema.id)
7790 .is_ok_and(|receipt| receipt.is_none())
7791 {
7792 if let Ok(_mutation) = self.model_management.begin_mutation(&schema.id) {
7799 if let Some(path) = registry.existing_local_artifact(&schema.id) {
7800 if std::fs::symlink_metadata(&path)
7801 .is_ok_and(|metadata| metadata.file_type().is_symlink())
7802 {
7803 let generation = self
7804 .resource_policy_generation
7805 .load(std::sync::atomic::Ordering::Acquire);
7806 let _ = self.model_management.record_managed_artifact(
7807 &schema.id,
7808 model_source_identity(schema),
7809 None,
7810 generation,
7811 true,
7812 path,
7813 );
7814 }
7815 }
7816 }
7817 }
7818 let receipt = self.model_management.load_receipt(&schema.id);
7819 let directory_cleanup_unsupported =
7820 !model_management::directory_removal_supported()
7821 && receipt.as_ref().is_ok_and(|receipt| {
7822 receipt.as_ref().is_some_and(|receipt| {
7823 receipt.artifact_kind
7824 == model_management::ManagedArtifactKind::Directory
7825 })
7826 });
7827 info.car_enabled = enabled;
7828 info.can_remove = enabled
7829 && self
7830 .model_management
7831 .can_remove(&schema.id)
7832 .unwrap_or(false);
7833 info.in_use = self.local_admission.active_request_count(&schema.id) > 0
7834 || self.local_admission.is_resident(&schema.id)
7835 || self.local_admission.teardown_pending(&schema.id)
7836 || self
7837 .model_management
7838 .model_in_use(&schema.id)
7839 .unwrap_or(true);
7840 info.management_evidence = if !enabled {
7841 Some("disabled_tombstone".into())
7842 } else if directory_cleanup_unsupported {
7843 Some("install_receipt_directory_cleanup_unsupported".into())
7844 } else if receipt.is_ok_and(|receipt| receipt.is_some()) {
7845 Some("install_receipt".into())
7846 } else if info.weights_ready {
7847 Some("shared_or_hand_installed".into())
7848 } else {
7849 None
7850 };
7851 }
7852 info
7853 })
7854 .collect()
7855 }
7856
7857 pub fn model_management_store(&self) -> &model_management::ModelManagementStore {
7858 &self.model_management
7859 }
7860
7861 fn ensure_model_enabled(&self, model_id: &str) -> Result<(), InferenceError> {
7862 if self.model_management.car_enabled(model_id)? {
7863 return Ok(());
7864 }
7865 Err(InferenceError::InferenceFailed(format!(
7866 "local model {model_id} was removed from CAR; reinstall it before use"
7867 )))
7868 }
7869
7870 pub fn available_model_upgrades(&self) -> Vec<ModelUpgrade> {
7872 self.unified_registry.available_upgrades()
7873 }
7874
7875 pub async fn check_upgrade_nudge(
7881 &self,
7882 inference_active: bool,
7883 ) -> (crate::nudge::NudgeDecision, crate::nudge::NudgeState) {
7884 let findings = self.detect_upgrades().await;
7885 let prefs = self.update_prefs();
7886 let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
7887 let now = std::time::SystemTime::now()
7888 .duration_since(std::time::UNIX_EPOCH)
7889 .map(|d| d.as_secs())
7890 .unwrap_or(0);
7891 let decision = crate::nudge::decide_nudge(
7892 &findings,
7893 &prefs,
7894 &state,
7895 now,
7896 crate::nudge::DEFAULT_THROTTLE_SECS,
7897 inference_active,
7898 );
7899 (decision, state)
7900 }
7901
7902 pub fn dismiss_upgrade_nudge(&self, dismiss_key: &str) -> Result<(), InferenceError> {
7905 let path = crate::nudge::NudgeState::default_path();
7906 let mut state = crate::nudge::NudgeState::load_from(&path);
7907 state.dismiss(dismiss_key);
7908 state
7909 .save_to(&path)
7910 .map_err(InferenceError::InferenceFailed)
7911 }
7912
7913 pub async fn check_concierge(
7920 &self,
7921 inference_active: bool,
7922 ) -> (
7923 Vec<crate::concierge::ConciergeSuggestion>,
7924 crate::nudge::NudgeState,
7925 ) {
7926 let prefs = self.update_prefs();
7927 let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
7928 let hw = crate::hardware::HardwareInfo::detect();
7929 let schemas = self.list_schemas();
7930 let refs: Vec<&ModelSchema> = schemas.iter().collect();
7931 let now = std::time::SystemTime::now()
7932 .duration_since(std::time::UNIX_EPOCH)
7933 .map(|d| d.as_secs())
7934 .unwrap_or(0);
7935 let suggestions = crate::concierge::decide_concierge(
7936 &refs,
7937 &hw,
7938 crate::concierge::DEFAULT_WATCHED_USE_CASES,
7939 crate::intent::QualityTier::Balanced,
7940 &prefs,
7941 &state,
7942 now,
7943 crate::concierge::DEFAULT_CONCIERGE_THROTTLE_SECS,
7944 inference_active,
7945 );
7946 (suggestions, state)
7947 }
7948
7949 pub fn dismiss_concierge_suggestion(&self, dismiss_key: &str) -> Result<(), InferenceError> {
7954 self.dismiss_upgrade_nudge(dismiss_key)
7955 }
7956
7957 pub fn dismiss_concierge_labeled(
7961 &self,
7962 dismiss_key: &str,
7963 reason: crate::concierge::DismissReason,
7964 ) -> Result<(), String> {
7965 let path = crate::nudge::NudgeState::default_path();
7966 let mut state = crate::nudge::NudgeState::load_from(&path);
7967 let now = std::time::SystemTime::now()
7968 .duration_since(std::time::UNIX_EPOCH)
7969 .map(|d| d.as_secs())
7970 .unwrap_or(0);
7971 state.dismiss_labeled(dismiss_key, reason, now);
7972 state.save_to(&path).map_err(|e| e.to_string())
7973 }
7974
7975 pub async fn check_canaries(&self) -> Vec<crate::intent::UseCase> {
7983 use crate::action_ledger::ConciergeActionKind;
7984 use crate::concierge::{
7985 canary_verdict, CanaryVerdict, CANARY_MIN_SAMPLES, CANARY_REGRESSION_MARGIN,
7986 };
7987 use std::collections::BTreeMap;
7988
7989 let actions = self.concierge_actions(0);
7990 let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
7991 let entries = crate::outcome::read_ledger(&ledger_path, 0);
7992
7993 let mut latest: BTreeMap<
7995 crate::intent::UseCase,
7996 &crate::action_ledger::ConciergeActionEntry,
7997 > = BTreeMap::new();
7998 for a in &actions {
7999 if a.project.is_some() {
8000 continue; }
8002 if let Some(uc) = a.use_case {
8003 latest.insert(uc, a);
8004 }
8005 }
8006
8007 let mut reverts: Vec<(crate::intent::UseCase, u64)> = Vec::new();
8012 let tracker = self.outcome_tracker.read().await;
8013 for (uc, a) in latest {
8014 if a.kind != ConciergeActionKind::SetDefault {
8017 continue;
8018 }
8019 let Some(prior) = a.prior_model_id.as_deref() else {
8020 continue; };
8022
8023 let (mut succ, mut total) = (0u64, 0u64);
8025 for e in &entries {
8026 if e.timestamp < a.timestamp
8027 || e.model_id != a.model_id
8028 || crate::usage_profile::use_case_for_task(e.task) != uc
8029 {
8030 continue;
8031 }
8032 match e.success {
8033 Some(true) => {
8034 succ += 1;
8035 total += 1;
8036 }
8037 Some(false) => total += 1,
8038 None => {}
8039 }
8040 }
8041 let new_rate = if total == 0 {
8042 None
8043 } else {
8044 Some(succ as f64 / total as f64)
8045 };
8046
8047 let (mut base_succ, mut base_total) = (0u64, 0u64);
8052 if let Some(profile) = tracker.profile(prior) {
8053 for t in [
8054 crate::outcome::InferenceTask::Generate,
8055 crate::outcome::InferenceTask::Embed,
8056 crate::outcome::InferenceTask::Classify,
8057 crate::outcome::InferenceTask::Code,
8058 crate::outcome::InferenceTask::Reasoning,
8059 ] {
8060 if crate::usage_profile::use_case_for_task(t) != uc {
8061 continue;
8062 }
8063 if let Some(ts) = profile.task_stats(t) {
8064 base_succ += ts.successes;
8065 base_total += ts.successes + ts.failures;
8066 }
8067 }
8068 }
8069 if base_total == 0 {
8072 continue;
8073 }
8074 let baseline = base_succ as f64 / base_total as f64;
8075
8076 if canary_verdict(
8077 new_rate,
8078 total,
8079 baseline,
8080 CANARY_MIN_SAMPLES,
8081 CANARY_REGRESSION_MARGIN,
8082 ) == CanaryVerdict::Revert
8083 {
8084 reverts.push((uc, a.seq));
8088 }
8089 }
8090 drop(tracker);
8091
8092 let mut reverted = Vec::new();
8095 for (uc, seq) in reverts {
8096 if self.rollback_lane_inner(uc, None, Some(seq)).await.is_ok() {
8097 tracing::info!(lane = ?uc, "concierge canary: auto-reverted a worse model switch");
8098 reverted.push(uc);
8099 }
8100 }
8101 reverted
8102 }
8103
8104 pub async fn concierge_ask(&self, question: &str) -> Result<String, String> {
8113 use std::fmt::Write as _;
8114 let status = self.concierge_status(false).await;
8115 let hw = crate::hardware::HardwareInfo::detect();
8116 let schemas = self.list_schemas();
8117 let refs: Vec<&ModelSchema> = schemas.iter().collect();
8118
8119 let mut evidence = String::new();
8120 evidence.push_str("OBSERVED USAGE (last 30 days):\n");
8121 if status.lanes.is_empty() {
8122 evidence.push_str(" (no usage recorded yet)\n");
8123 }
8124 for lane in &status.lanes {
8125 let rate = lane
8126 .success_rate()
8127 .map(|r| format!("{:.0}% success", r * 100.0))
8128 .unwrap_or_else(|| "no resolved signal".into());
8129 let _ = writeln!(
8130 evidence,
8131 " {:?}: {} calls, {}{}",
8132 lane.use_case,
8133 lane.calls,
8134 rate,
8135 if lane.failing_models.is_empty() {
8136 String::new()
8137 } else {
8138 format!(
8139 ", failing on {}",
8140 lane.failing_models
8141 .iter()
8142 .cloned()
8143 .collect::<Vec<_>>()
8144 .join(", ")
8145 )
8146 }
8147 );
8148 }
8149 evidence.push_str("\nMODEL HEALTH:\n");
8150 for m in &status.models {
8151 let success = match m.success_rate {
8152 Some(r) => format!("{:.0}% success", r * 100.0),
8153 None => "no resolved signal".to_string(),
8154 };
8155 let _ = writeln!(
8156 evidence,
8157 " {}: {} calls, {}, {:.0}ms avg{}",
8158 m.model_id,
8159 m.calls,
8160 success,
8161 m.avg_latency_ms,
8162 if m.excluded { " (excluded)" } else { "" }
8163 );
8164 }
8165 let mut menu_lanes: Vec<crate::intent::UseCase> =
8171 crate::concierge::DEFAULT_WATCHED_USE_CASES.to_vec();
8172 for lane in &status.lanes {
8173 if !menu_lanes.contains(&lane.use_case) {
8174 menu_lanes.push(lane.use_case);
8175 }
8176 }
8177 evidence.push_str("\nGROUNDED CANDIDATES (fit verified for this machine):\n");
8178 for uc in menu_lanes {
8179 let set = crate::recommend::recommend(
8180 &refs,
8181 &hw,
8182 uc,
8183 crate::intent::QualityTier::Balanced,
8184 crate::intent::Privacy::OnDevice,
8185 );
8186 for p in set.picks.iter().take(3) {
8187 let _ = writeln!(
8188 evidence,
8189 " [{:?}] {} — {}{}",
8190 uc,
8191 p.display_name,
8192 if p.already_installed {
8193 "installed"
8194 } else {
8195 "available"
8196 },
8197 if p.fit == crate::recommend::FitStatus::Fits {
8198 ", fits"
8199 } else {
8200 ", does NOT fit"
8201 }
8202 );
8203 }
8204 }
8205 if let Some(s) = &status.decision.suggestion {
8206 let _ = writeln!(evidence, "\nCURRENT SUGGESTION: {}", s.message);
8207 }
8208
8209 let prompt = format!(
8210 "You are CAR's model concierge. Answer the user's question ONLY from the \
8211 EVIDENCE provided as context — the user's observed model usage, model \
8212 health, and the grounded candidate menu (the only models you may \
8213 mention). NEVER invent a model name and NEVER claim a model fits or is \
8214 better than the evidence states. If the evidence doesn't answer the \
8215 question, say so plainly. Be concise and concrete.\n\nQUESTION: {question}"
8216 );
8217 let evidence_lc = evidence.to_lowercase();
8218 let req = crate::tasks::generate::GenerateRequest {
8219 prompt,
8220 context: Some(evidence),
8221 intent: Some(crate::intent::IntentHint {
8222 task: Some(crate::intent::TaskHint::Chat),
8223 prefer_local: true,
8224 ..Default::default()
8225 }),
8226 ..Default::default()
8227 };
8228 let answer = self.generate(req).await.map_err(|e| e.to_string())?;
8229
8230 const FAMILIES: [&str; 9] = [
8234 "llama", "gpt", "mistral", "gemma", "deepseek", "phi", "claude", "grok", "qwen",
8235 ];
8236 let answer_lc = answer.to_lowercase();
8237 let leaked = FAMILIES
8238 .iter()
8239 .any(|fam| answer_lc.contains(fam) && !evidence_lc.contains(fam));
8240 let answer = if leaked {
8241 format!(
8242 "{answer}\n\n(Note: I can only verify models in your catalog — any others \
8243 named above aren't checked for fit on your machine.)"
8244 )
8245 } else {
8246 answer
8247 };
8248 Ok(answer)
8249 }
8250
8251 pub async fn refresh_catalog(&self) -> Result<usize, String> {
8259 let url = std::env::var("CAR_CATALOG_URL")
8260 .map_err(|_| "no catalog source configured (set CAR_CATALOG_URL)".to_string())?;
8261 let pubkey = std::env::var("CAR_CATALOG_PUBKEY")
8262 .map_err(|_| "no catalog public key configured (set CAR_CATALOG_PUBKEY)".to_string())?;
8263 let http = crate::tls_client::catalog_refresh_client();
8268 let verified = crate::catalog::fetch_and_verify(&http, &url, &pubkey).await?;
8269 let path = crate::catalog::cache_path(&self.config.state_root);
8270 crate::catalog::install_if_newer(&path, &verified, &pubkey).await
8275 }
8276
8277 pub async fn discover_models(&self) -> Result<usize, String> {
8286 use crate::schema::{ModelSource, TrustTier};
8287 let template = self
8294 .unified_registry
8295 .all()
8296 .filter(|m| {
8297 m.provider.eq_ignore_ascii_case("openai")
8298 && m.trust_tier == TrustTier::Curated
8299 && matches!(m.source, ModelSource::RemoteApi { .. })
8300 })
8301 .max_by(|a, b| {
8302 a.capabilities
8303 .len()
8304 .cmp(&b.capabilities.len())
8305 .then_with(|| a.id.cmp(&b.id))
8306 })
8307 .cloned();
8308 let Some(template) = template else {
8309 return Ok(0); };
8311 let (endpoint, api_key_env) = match &template.source {
8312 ModelSource::RemoteApi {
8313 endpoint,
8314 api_key_env,
8315 ..
8316 } => (endpoint.clone(), api_key_env.clone()),
8317 _ => return Ok(0),
8318 };
8319 let Some(models_url) = crate::discovery::models_url_from_endpoint(&endpoint) else {
8320 return Ok(0);
8321 };
8322 let key = match car_secrets::resolve_env_or_keychain(&api_key_env) {
8323 Some(k) if !k.is_empty() => k,
8324 _ => return Ok(0), };
8326
8327 let http = reqwest::Client::builder()
8331 .timeout(std::time::Duration::from_secs(20))
8332 .build()
8333 .map_err(|e| format!("discovery client: {e}"))?;
8334 let ids = crate::discovery::fetch_model_ids(&http, &models_url, &key).await?;
8335
8336 let cache = crate::discovery::cache_path(&self.config.state_models_dir());
8338 let mut cached = crate::discovery::load_cache(&cache);
8339 let mut have: std::collections::HashSet<String> =
8340 cached.iter().map(|m| m.id.clone()).collect();
8341 for id in ids {
8342 if !crate::discovery::is_chat_model(&id) {
8343 continue;
8344 }
8345 let schema = crate::discovery::discovered_schema("openai", &id, &template);
8346 if self.unified_registry.get(&schema.id).is_some() {
8352 continue;
8353 }
8354 if have.insert(schema.id.clone()) {
8355 cached.push(schema);
8356 }
8357 }
8358 let count = cached.len();
8359 crate::discovery::save_cache(&cache, &cached)?;
8360 Ok(count)
8361 }
8362
8363 pub fn lane_defaults(&self) -> crate::lane_defaults::LaneDefaults {
8365 self.lane_defaults_cache.read().unwrap().clone()
8366 }
8367
8368 pub fn lane_default(
8372 &self,
8373 project: Option<&str>,
8374 use_case: crate::intent::UseCase,
8375 ) -> Option<String> {
8376 self.lane_defaults_cache
8377 .read()
8378 .unwrap()
8379 .resolve(project, use_case)
8380 .map(str::to_string)
8381 }
8382
8383 fn model_is_excluded(
8384 exclude_set: &std::collections::HashSet<String>,
8385 registry: &UnifiedRegistry,
8386 model: &str,
8387 ) -> bool {
8388 registry
8389 .get(model)
8390 .or_else(|| registry.find_by_name(model))
8391 .map(|schema| exclude_set.contains(&schema.id))
8392 .unwrap_or_else(|| exclude_set.contains(model))
8393 }
8394
8395 fn lane_pin_for(
8401 &self,
8402 req: &GenerateRequest,
8403 routing_registry: &UnifiedRegistry,
8404 ) -> Option<String> {
8405 let task = req.intent.as_ref().and_then(|h| h.task)?;
8406 let use_case = crate::usage_profile::use_case_for_task_hint(task);
8407 let id = self.lane_default(None, use_case)?;
8408 let known = routing_registry
8413 .get(&id)
8414 .or_else(|| routing_registry.find_by_name(&id));
8415 match known {
8416 Some(s) if s.available_now() => Some(id),
8417 _ => None,
8418 }
8419 }
8420
8421 pub fn set_lane_default(
8424 &self,
8425 project: Option<String>,
8426 use_case: crate::intent::UseCase,
8427 model_id: &str,
8428 ) -> Result<(), String> {
8429 let now = std::time::SystemTime::now()
8430 .duration_since(std::time::UNIX_EPOCH)
8431 .map(|d| d.as_secs())
8432 .unwrap_or(0);
8433 let mut defaults = self.lane_defaults_cache.write().unwrap();
8436 defaults.set(project, use_case, model_id.to_string(), now);
8437 crate::lane_defaults::save_to(&crate::lane_defaults::default_path(), &defaults)
8438 .map_err(|e| e.to_string())
8439 }
8440
8441 pub fn clear_lane_default(
8444 &self,
8445 project: Option<&str>,
8446 use_case: crate::intent::UseCase,
8447 ) -> Result<bool, String> {
8448 let mut defaults = self.lane_defaults_cache.write().unwrap();
8449 let removed = defaults.clear(project, use_case);
8450 crate::lane_defaults::save_to(&crate::lane_defaults::default_path(), &defaults)
8451 .map_err(|e| e.to_string())?;
8452 Ok(removed)
8453 }
8454
8455 pub async fn user_set_lane_default(
8463 &self,
8464 use_case: crate::intent::UseCase,
8465 model_id: &str,
8466 project: Option<String>,
8467 ) -> Result<(), String> {
8468 use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8469 let _guard = self.concierge_action_lock.lock().await;
8470 let prior = self.lane_default(project.as_deref(), use_case);
8471 self.set_lane_default(project.clone(), use_case, model_id)?;
8472 self.record_concierge_action(ConciergeActionEntry {
8473 seq: 0,
8474 kind: ConciergeActionKind::SetDefault,
8475 model_id: model_id.to_string(),
8476 use_case: Some(use_case),
8477 project,
8478 prior_model_id: prior,
8479 detail: "user set lane default".into(),
8480 timestamp: now_unix(),
8481 });
8482 Ok(())
8483 }
8484
8485 pub async fn user_clear_lane_default(
8490 &self,
8491 use_case: crate::intent::UseCase,
8492 project: Option<String>,
8493 ) -> Result<bool, String> {
8494 use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8495 let _guard = self.concierge_action_lock.lock().await;
8496 let prior = self.lane_default(project.as_deref(), use_case);
8497 let removed = self.clear_lane_default(project.as_deref(), use_case)?;
8498 if removed {
8499 self.record_concierge_action(ConciergeActionEntry {
8500 seq: 0,
8501 kind: ConciergeActionKind::ClearDefault,
8502 model_id: prior.clone().unwrap_or_default(),
8503 use_case: Some(use_case),
8504 project,
8505 prior_model_id: prior,
8506 detail: "user cleared lane default".into(),
8507 timestamp: now_unix(),
8508 });
8509 }
8510 Ok(removed)
8511 }
8512
8513 pub fn concierge_actions(
8515 &self,
8516 limit: usize,
8517 ) -> Vec<crate::action_ledger::ConciergeActionEntry> {
8518 crate::action_ledger::read_actions(&crate::action_ledger::default_path(), limit)
8519 }
8520
8521 fn record_concierge_action(&self, mut entry: crate::action_ledger::ConciergeActionEntry) {
8522 entry.seq = self
8523 .concierge_action_seq
8524 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8525 if let Err(e) =
8526 crate::action_ledger::append_action(&crate::action_ledger::default_path(), &entry)
8527 {
8528 tracing::debug!("record concierge action failed: {e}");
8529 }
8530 }
8531
8532 pub async fn apply_concierge(
8544 &self,
8545 use_case: crate::intent::UseCase,
8546 model_id: &str,
8547 project: Option<String>,
8548 ) -> Result<crate::action_ledger::ConciergeApplyResult, String> {
8549 use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8550 let now = || {
8551 std::time::SystemTime::now()
8552 .duration_since(std::time::UNIX_EPOCH)
8553 .map(|d| d.as_secs())
8554 .unwrap_or(0)
8555 };
8556 if let Some(schema) = self
8561 .list_schemas()
8562 .into_iter()
8563 .find(|s| s.id == model_id || s.name == model_id)
8564 {
8565 let serves = use_case
8566 .required_capabilities()
8567 .iter()
8568 .all(|c| schema.capabilities.contains(c));
8569 if !serves {
8570 return Err(format!(
8571 "model '{model_id}' does not serve the {use_case:?} lane"
8572 ));
8573 }
8574 }
8575
8576 self.pull_model(model_id).await.map_err(|e| e.to_string())?;
8581
8582 let _guard = self.concierge_action_lock.lock().await;
8585 let prior = self.lane_default(project.as_deref(), use_case);
8588 self.record_concierge_action(ConciergeActionEntry {
8589 seq: 0, kind: ConciergeActionKind::Install,
8591 model_id: model_id.to_string(),
8592 use_case: Some(use_case),
8593 project: project.clone(),
8594 prior_model_id: None,
8595 detail: "concierge apply: ensured model present".into(),
8596 timestamp: now(),
8597 });
8598
8599 self.set_lane_default(project.clone(), use_case, model_id)?;
8601 self.record_concierge_action(ConciergeActionEntry {
8602 seq: 0, kind: ConciergeActionKind::SetDefault,
8604 model_id: model_id.to_string(),
8605 use_case: Some(use_case),
8606 project: project.clone(),
8607 prior_model_id: prior.clone(),
8608 detail: "concierge apply: set lane default".into(),
8609 timestamp: now(),
8610 });
8611
8612 Ok(crate::action_ledger::ConciergeApplyResult {
8613 model_id: model_id.to_string(),
8614 use_case,
8615 installed: true,
8616 set_default: true,
8617 prior_model_id: prior,
8618 })
8619 }
8620
8621 pub async fn rollback_lane(
8626 &self,
8627 use_case: crate::intent::UseCase,
8628 project: Option<String>,
8629 ) -> Result<Option<String>, String> {
8630 self.rollback_lane_inner(use_case, project, None).await
8631 }
8632
8633 async fn rollback_lane_inner(
8639 &self,
8640 use_case: crate::intent::UseCase,
8641 project: Option<String>,
8642 expected_anchor_seq: Option<u64>,
8643 ) -> Result<Option<String>, String> {
8644 use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8645 let _guard = self.concierge_action_lock.lock().await;
8646 let actions = self.concierge_actions(0);
8651 let anchor = actions.iter().rev().find(|a| {
8652 matches!(
8653 a.kind,
8654 ConciergeActionKind::SetDefault | ConciergeActionKind::Rollback
8655 ) && a.use_case == Some(use_case)
8656 && a.project == project
8657 });
8658 let set = match anchor {
8659 None => return Err("no prior set-default to roll back".into()),
8660 Some(a) if a.kind == ConciergeActionKind::Rollback => {
8661 return Err("already rolled back to the prior default; nothing to undo".into())
8662 }
8663 Some(a) => a,
8664 };
8665 if let Some(seq) = expected_anchor_seq {
8668 if set.seq != seq {
8669 return Err("lane default changed since the canary decision; not reverting".into());
8670 }
8671 }
8672 let prior = set.prior_model_id.clone();
8673 let now = std::time::SystemTime::now()
8674 .duration_since(std::time::UNIX_EPOCH)
8675 .map(|d| d.as_secs())
8676 .unwrap_or(0);
8677
8678 match &prior {
8679 Some(m) => self.set_lane_default(project.clone(), use_case, m)?,
8680 None => {
8681 self.clear_lane_default(project.as_deref(), use_case)?;
8682 }
8683 }
8684 let detail = match &prior {
8685 Some(_) => "concierge rollback: restored prior lane default",
8686 None => "concierge rollback: cleared lane default (no prior)",
8687 };
8688 self.record_concierge_action(ConciergeActionEntry {
8689 seq: 0, kind: ConciergeActionKind::Rollback,
8691 model_id: prior.clone().unwrap_or_default(),
8692 use_case: Some(use_case),
8693 project,
8694 prior_model_id: Some(set.model_id.clone()),
8695 detail: detail.into(),
8696 timestamp: now,
8697 });
8698 Ok(prior)
8699 }
8700
8701 pub async fn concierge_status(
8706 &self,
8707 inference_active: bool,
8708 ) -> crate::concierge::ConciergeStatus {
8709 const USAGE_WINDOW_SECS: u64 = 30 * 24 * 60 * 60;
8711
8712 let prefs = self.update_prefs();
8713 let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
8714 let hw = crate::hardware::HardwareInfo::detect();
8715 let schemas = self.list_schemas();
8716 let refs: Vec<&ModelSchema> = schemas.iter().collect();
8717 let now = std::time::SystemTime::now()
8718 .duration_since(std::time::UNIX_EPOCH)
8719 .map(|d| d.as_secs())
8720 .unwrap_or(0);
8721
8722 let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
8723 let entries = crate::outcome::read_ledger(&ledger_path, 0);
8724 let usage =
8725 crate::usage_profile::UsageProfile::from_ledger(&entries, now, USAGE_WINDOW_SECS);
8726
8727 let decision = crate::concierge::evaluate_concierge(
8728 &refs,
8729 &hw,
8730 &usage,
8731 crate::intent::QualityTier::Balanced,
8732 &prefs,
8733 &state,
8734 now,
8735 crate::concierge::DEFAULT_CONCIERGE_THROTTLE_SECS,
8736 inference_active,
8737 );
8738
8739 let tracker = self.outcome_tracker.read().await;
8740 let models = tracker
8741 .export_profiles()
8742 .iter()
8743 .map(|p| crate::concierge::ModelHealth {
8744 model_id: p.model_id.clone(),
8745 calls: p.total_calls,
8746 success_rate: p.success_rate_resolved(),
8750 avg_latency_ms: p.avg_latency_ms(),
8751 quality: p.ema_quality,
8752 excluded: tracker.is_excluded(&p.model_id),
8753 })
8754 .collect();
8755 drop(tracker);
8756
8757 const STALE_VERIFY_SECS: u64 = 14 * 24 * 60 * 60;
8762 let actions = self.concierge_actions(0);
8763 let mut latest: std::collections::BTreeMap<
8764 crate::intent::UseCase,
8765 &crate::action_ledger::ConciergeActionEntry,
8766 > = std::collections::BTreeMap::new();
8767 for a in &actions {
8768 if a.project.is_none() {
8769 if let Some(uc) = a.use_case {
8770 latest.insert(uc, a);
8771 }
8772 }
8773 }
8774 let mut pending_verification = Vec::new();
8775 for (uc, a) in latest {
8776 if a.kind != crate::action_ledger::ConciergeActionKind::SetDefault {
8777 continue;
8778 }
8779 if now.saturating_sub(a.timestamp) < STALE_VERIFY_SECS {
8780 continue; }
8782 let resolved = entries
8783 .iter()
8784 .filter(|e| {
8785 e.timestamp >= a.timestamp
8786 && e.model_id == a.model_id
8787 && crate::usage_profile::use_case_for_task(e.task) == uc
8788 && e.success.is_some()
8789 })
8790 .count() as u64;
8791 if resolved < crate::concierge::CANARY_MIN_SAMPLES {
8792 pending_verification.push(crate::concierge::PendingVerification {
8793 use_case: uc,
8794 model_id: a.model_id.clone(),
8795 set_at: a.timestamp,
8796 resolved_samples: resolved,
8797 needed: crate::concierge::CANARY_MIN_SAMPLES,
8798 });
8799 }
8800 }
8801
8802 crate::concierge::ConciergeStatus {
8803 lanes: usage.active_lanes().into_iter().cloned().collect(),
8804 decision,
8805 models,
8806 pending_verification,
8807 }
8808 }
8809
8810 pub async fn detect_upgrades(&self) -> Vec<crate::upgrade::UpgradeFinding> {
8814 let prefs = self.update_prefs();
8815 let curated = self.unified_registry.available_upgrades();
8816 let schemas = self.list_schemas();
8817 let refs: Vec<&ModelSchema> = schemas.iter().collect();
8818 let probe = crate::upgrade::HuggingFaceProbe::new();
8819 let now = std::time::SystemTime::now()
8820 .duration_since(std::time::UNIX_EPOCH)
8821 .map(|d| d.as_secs())
8822 .unwrap_or(0);
8823 crate::upgrade::detect_upgrades(
8824 curated,
8825 &refs,
8826 &prefs,
8827 &probe,
8828 &crate::upgrade::UpgradeCache::default_path(),
8829 now,
8830 crate::upgrade::DEFAULT_TTL_SECS,
8831 )
8832 .await
8833 }
8834
8835 pub fn list_schemas(&self) -> Vec<ModelSchema> {
8838 self.catalog_registry_snapshot()
8839 .list()
8840 .into_iter()
8841 .cloned()
8842 .collect()
8843 }
8844
8845 pub fn catalog_snapshot(&self) -> Result<CatalogSnapshot, String> {
8849 CatalogSnapshot::new(self.unified_registry.list().into_iter().cloned())
8850 }
8851
8852 pub fn registered_schema(&self, id: &str) -> Option<ModelSchema> {
8857 self.unified_registry.registered_schema(id).cloned()
8858 }
8859
8860 pub fn list_models(&self) -> Vec<models::ModelInfo> {
8861 self.registry.list_models()
8862 }
8863
8864 pub fn knows_model(&self, name: &str) -> bool {
8877 self.model_schema(name).is_some()
8878 }
8879
8880 pub fn model_schema(&self, name: &str) -> Option<&ModelSchema> {
8898 self.unified_registry
8899 .get(name)
8900 .or_else(|| self.unified_registry.find_by_name(name))
8901 }
8902
8903 fn first_installed_local_model(&self, needs_tools: bool) -> Option<String> {
8916 let mut candidates: Vec<_> = self
8924 .unified_registry
8925 .all()
8926 .filter(|s| s.is_local() && !s.is_vllm_mlx())
8933 .filter(|s| !s.is_foundation_models() || s.available)
8949 .filter(|s| s.has_capability(ModelCapability::Generate))
8950 .filter(|s| !needs_tools || s.has_capability(ModelCapability::ToolUse))
8954 .filter(|s| self.model_management.car_enabled(&s.id).unwrap_or(false))
8955 .filter(|s| self.unified_registry.ready_without_download(&s.id) == Some(true))
8956 .collect();
8957 candidates.sort_by_key(|s| s.size_mb());
8959
8960 if let Some(avail) = crate::hardware::available_ram_mb() {
8972 const WORKING_RESERVE_MB: u64 = 2048;
8973 candidates.retain(|s| s.size_mb().saturating_add(WORKING_RESERVE_MB) <= avail);
8974 }
8975
8976 candidates.first().map(|s| s.name.clone())
8977 }
8978
8979 pub async fn pull_model(&self, name: &str) -> Result<std::path::PathBuf, InferenceError> {
8981 self.pull_model_with_progress(name, &crate::download::ProgressSink::none())
8982 .await
8983 }
8984
8985 pub async fn pull_model_with_progress(
8989 &self,
8990 name: &str,
8991 sink: &crate::download::ProgressSink,
8992 ) -> Result<std::path::PathBuf, InferenceError> {
8993 let schema = self
8994 .unified_registry
8995 .find_by_name(name)
8996 .or_else(|| self.unified_registry.get(name))
8997 .ok_or_else(|| InferenceError::ModelNotFound(name.to_string()))?;
8998 let _mutation = self.model_management.begin_mutation(&schema.id)?;
8999 if let Some(receipt) = self.model_management.load_receipt(&schema.id)? {
9000 let _ = self.model_management.can_remove(&schema.id)?;
9004 self.model_management.clear_tombstone(&schema.id)?;
9005 return Ok(receipt.managed_path);
9006 }
9007 if let Some(receipt) = self.model_management.resume_install_intent(&schema.id)? {
9008 return Ok(receipt.managed_path);
9009 }
9010 let expected_managed = self.model_management.models_dir().join(&schema.name);
9011 if std::fs::symlink_metadata(&expected_managed).is_ok() {
9012 return Err(InferenceError::InferenceFailed(format!(
9013 "model {} has a pre-existing unreceipted artifact at {}; use models.adopt or move it before pulling",
9014 schema.id,
9015 expected_managed.display()
9016 )));
9017 }
9018 let staging = self.model_management.create_install_staging(&schema.id)?;
9019 let installed = match self
9020 .unified_registry
9021 .ensure_local_with_progress_staged(&schema.id, sink, &staging)
9022 .await
9023 {
9024 Ok(installed) => installed,
9025 Err(error) => {
9026 self.model_management.discard_install_staging(&staging);
9027 return Err(error);
9028 }
9029 };
9030 let installed_is_staging = match (installed.canonicalize(), staging.canonicalize()) {
9031 (Ok(installed), Ok(staging)) => installed == staging,
9032 _ => false,
9033 };
9034 let generation = self
9035 .resource_policy_generation
9036 .load(std::sync::atomic::Ordering::Acquire);
9037 let managed_path = self.model_management.models_dir().join(&schema.name);
9038 let managed = if installed_is_staging {
9039 let receipt = self.model_management.install_receipt_for_publication(
9040 &schema.id,
9041 model_source_identity(schema),
9042 generation,
9043 false,
9044 managed_path,
9045 model_management::ManagedArtifactKind::Directory,
9046 &staging,
9047 )?;
9048 self.model_management
9049 .begin_install_intent(receipt, Some(&staging))?;
9050 self.model_management
9051 .publish_install_staging(&schema.id, &staging, &schema.name)?
9052 } else {
9053 self.model_management.discard_install_staging(&staging);
9054 let receipt = self.model_management.install_receipt_for_publication(
9055 &schema.id,
9056 model_source_identity(schema),
9057 generation,
9058 false,
9059 managed_path,
9060 model_management::ManagedArtifactKind::Symlink,
9061 &installed,
9062 )?;
9063 self.model_management.begin_install_intent(receipt, None)?;
9064 self.model_management
9065 .materialize_managed_projection(&schema.name, &installed)?
9066 };
9067 let receipt = self
9068 .model_management
9069 .resume_install_intent(&schema.id)?
9070 .ok_or_else(|| {
9071 InferenceError::InferenceFailed(format!(
9072 "model {} publication completed without a durable install intent",
9073 schema.id
9074 ))
9075 })?;
9076 debug_assert_eq!(receipt.managed_path, managed);
9077 Ok(receipt.managed_path)
9078 }
9079
9080 pub async fn adopt_model_into_car(
9084 &self,
9085 model_id: &str,
9086 ) -> Result<model_management::InstallReceipt, InferenceError> {
9087 let schema = self
9088 .unified_registry
9089 .get(model_id)
9090 .or_else(|| self.unified_registry.find_by_name(model_id))
9091 .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
9092 if !schema.is_local() || !schema.downloads_weights() {
9093 return Err(InferenceError::InferenceFailed(format!(
9094 "model {} is not a CAR-manageable local artifact",
9095 schema.id
9096 )));
9097 }
9098 let existing = self
9099 .unified_registry
9100 .existing_local_artifact(&schema.id)
9101 .ok_or_else(|| {
9102 InferenceError::InferenceFailed(format!(
9103 "model {} has no usable local artifact to adopt",
9104 schema.id
9105 ))
9106 })?;
9107 let _mutation = self.model_management.begin_mutation(&schema.id)?;
9108 let existing_is_managed_symlink = std::fs::symlink_metadata(&existing)
9109 .is_ok_and(|metadata| metadata.file_type().is_symlink())
9110 && existing.parent().is_some_and(|parent| {
9111 parent.canonicalize().ok() == self.model_management.models_dir().canonicalize().ok()
9112 });
9113 if let Some(receipt) = self.model_management.resume_install_intent(&schema.id)? {
9114 return Ok(receipt);
9115 }
9116 let generation = self
9117 .resource_policy_generation
9118 .load(std::sync::atomic::Ordering::Acquire);
9119 let managed = if existing_is_managed_symlink {
9120 existing
9121 } else {
9122 let managed = self.model_management.adopted_projection_path(&schema.id)?;
9123 let receipt = self.model_management.install_receipt_for_publication(
9124 &schema.id,
9125 model_source_identity(schema),
9126 generation,
9127 true,
9128 managed,
9129 model_management::ManagedArtifactKind::Symlink,
9130 &existing,
9131 )?;
9132 self.model_management.begin_install_intent(receipt, None)?;
9133 self.model_management
9134 .materialize_adopted_projection(&schema.id, &existing)?
9135 };
9136 if existing_is_managed_symlink {
9137 self.model_management
9138 .record_managed_artifact(
9139 &schema.id,
9140 model_source_identity(schema),
9141 None,
9142 generation,
9143 true,
9144 managed,
9145 )
9146 .map_err(InferenceError::from)
9147 } else {
9148 self.model_management
9149 .resume_install_intent(&schema.id)?
9150 .ok_or_else(|| {
9151 InferenceError::InferenceFailed(format!(
9152 "model {} adoption completed without a durable install intent",
9153 schema.id
9154 ))
9155 })
9156 }
9157 }
9158
9159 pub async fn remove_model_from_car(
9162 &self,
9163 model_id: &str,
9164 ) -> Result<model_management::RemoveFromCarResult, InferenceError> {
9165 let schema = self
9166 .unified_registry
9167 .get(model_id)
9168 .or_else(|| self.unified_registry.find_by_name(model_id))
9169 .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
9170 if !schema.is_local() || !schema.downloads_weights() {
9171 return Err(InferenceError::InferenceFailed(format!(
9172 "model {} is not a CAR-owned local artifact",
9173 schema.id
9174 )));
9175 }
9176 if self.model_management.load_receipt(&schema.id)?.is_none() {
9177 if self.model_management.car_enabled(&schema.id)? {
9178 return Err(model_management::ModelManagementError::MissingReceipt {
9179 model_id: schema.id.clone(),
9180 }
9181 .into());
9182 }
9183 let generation = self
9187 .resource_policy_generation
9188 .load(std::sync::atomic::Ordering::Acquire)
9189 .saturating_add(1);
9190 return self
9191 .model_management
9192 .begin_mutation(&schema.id)?
9193 .remove(generation)
9194 .map_err(InferenceError::from);
9195 }
9196 let mutation = self.model_management.begin_mutation(&schema.id)?;
9197 let _maintenance = self
9198 .prepare_local_model_removal(&schema.id)
9199 .await
9200 .map_err(|error| InferenceError::InferenceFailed(error.to_string()))?;
9201 let generation = self
9202 .resource_policy_generation
9203 .load(std::sync::atomic::Ordering::Acquire)
9204 .saturating_add(1);
9205 mutation.remove(generation).map_err(InferenceError::from)
9206 }
9207
9208 pub fn update_prefs(&self) -> crate::update_prefs::UpdatePreferences {
9213 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
9214 crate::update_prefs::UpdatePreferences::load_effective(&cwd).unwrap_or_default()
9215 }
9216
9217 pub fn set_update_prefs(
9219 &self,
9220 prefs: &crate::update_prefs::UpdatePreferences,
9221 ) -> Result<(), InferenceError> {
9222 prefs.save().map_err(InferenceError::InferenceFailed)
9223 }
9224
9225 #[deprecated(note = "use async remove_model_from_car for receipt-backed safe removal")]
9229 pub fn remove_model(&self, name: &str) -> Result<(), InferenceError> {
9230 Err(InferenceError::InferenceFailed(format!(
9231 "legacy removal for {name} is disabled; use async receipt-backed remove_model_from_car"
9232 )))
9233 }
9234
9235 pub fn register_model(&mut self, schema: ModelSchema) {
9241 self.unified_registry.register(schema);
9242 }
9243
9244 pub fn register_user_model(&mut self, schema: ModelSchema) {
9246 self.unified_registry.register_user_model(schema);
9247 }
9248
9249 pub async fn discover_vllm_mlx_models(&mut self) -> usize {
9252 let config = vllm_mlx::VllmMlxConfig::default();
9253 if !config.auto_discover {
9254 return 0;
9255 }
9256 vllm_mlx::discover_and_register(&config, &mut self.unified_registry).await
9257 }
9258
9259 pub fn outcome_tracker(&self) -> Arc<RwLock<OutcomeTracker>> {
9261 self.outcome_tracker.clone()
9262 }
9263
9264 async fn auto_save_outcomes(&self) {
9272 const OUTCOME_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
9273
9274 if crate::offload::is_offload_worker() {
9282 return;
9283 }
9284
9285 let due = {
9287 let last = self.last_outcome_flush.lock().unwrap();
9288 last.is_none_or(|t| t.elapsed() >= OUTCOME_FLUSH_INTERVAL)
9289 };
9290 if due {
9291 match self.persist_outcomes().await {
9292 Ok(did) => {
9293 if did {
9294 *self.last_outcome_flush.lock().unwrap() = Some(Instant::now());
9295 }
9296 }
9297 Err(e) => tracing::debug!("auto-save outcomes failed: {}", e),
9298 }
9299 }
9300
9301 if let Err(e) = self.save_key_pool_stats().await {
9302 tracing::debug!("auto-save key pool stats failed: {}", e);
9303 }
9304 }
9305
9306 async fn persist_outcomes(&self) -> Result<bool, std::io::Error> {
9312 const PENDING_TTL_SECS: u64 = 300;
9313 let entries = {
9317 let mut tracker = self.outcome_tracker.write().await;
9318 tracker.sweep_pending(PENDING_TTL_SECS);
9319 tracker.drain_ledger()
9320 };
9321 let mut did = false;
9322 let ledger_disabled = std::env::var_os("CAR_NO_OUTCOME_LEDGER").is_some();
9327 if !entries.is_empty() && !ledger_disabled {
9328 let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9329 let _guard = self.ledger_io_lock.lock().await;
9330 crate::outcome::append_ledger_entries(&ledger_path, &entries)?;
9331 did = true;
9332 }
9333 let profiles_path = self.config.state_models_dir().join("outcome_profiles.json");
9335 let wrote = {
9336 let mut tracker = self.outcome_tracker.write().await;
9337 tracker.save_if_dirty(&profiles_path)?
9338 };
9339 Ok(did || wrote)
9340 }
9341
9342 pub async fn save_outcomes(&self) -> Result<(), std::io::Error> {
9347 let tracker = self.outcome_tracker.read().await;
9348 let path = self.config.state_models_dir().join("outcome_profiles.json");
9349 tracker.save_to_file(&path)
9350 }
9351
9352 pub async fn flush_outcomes(&self) -> Result<bool, std::io::Error> {
9358 let did = self.persist_outcomes().await?;
9359 if did {
9360 *self.last_outcome_flush.lock().unwrap() = Some(Instant::now());
9361 }
9362 Ok(did)
9363 }
9364
9365 pub async fn prune_outcome_ledger(&self, max_entries: usize) -> std::io::Result<()> {
9368 let path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9369 let _guard = self.ledger_io_lock.lock().await;
9370 crate::outcome::prune_ledger(&path, max_entries)
9371 }
9372
9373 pub async fn save_key_pool_stats(&self) -> Result<(), std::io::Error> {
9375 let path = self.config.state_models_dir().join("key_pool_stats.json");
9376 self.remote_backend.key_pool.save_stats(&path).await
9377 }
9378
9379 pub async fn key_pool_stats(
9381 &self,
9382 ) -> std::collections::HashMap<String, Vec<key_pool::KeyStats>> {
9383 self.remote_backend.key_pool.all_stats().await
9384 }
9385
9386 pub async fn export_profiles(&self) -> Vec<ModelProfile> {
9388 let tracker = self.outcome_tracker.read().await;
9389 tracker.export_profiles()
9390 }
9391
9392 pub fn outcome_scoreboard(&self) -> crate::scoreboard::Scoreboard {
9400 let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9401 let entries = crate::outcome::read_ledger(&ledger_path, 0);
9402 crate::calibration::ShadowCalibration::from_ledger(&entries).emit();
9406 crate::scoreboard::Scoreboard::from_ledger(&entries, |id| {
9407 let s = self
9408 .unified_registry
9409 .get(id)
9410 .or_else(|| self.unified_registry.find_by_name(id))?;
9411 match (s.cost.input_per_mtok, s.cost.output_per_mtok) {
9412 (Some(input_per_mtok), Some(output_per_mtok)) => {
9416 Some(crate::scoreboard::PriceModel {
9417 input_per_mtok,
9418 output_per_mtok,
9419 cache: s.cache_rates(),
9420 is_estimate: !s.cost.pricing_tiers.is_empty(),
9421 })
9422 }
9423 _ => None,
9424 }
9425 })
9426 }
9427
9428 pub async fn import_profiles(&self, profiles: Vec<ModelProfile>) {
9430 let mut tracker = self.outcome_tracker.write().await;
9431 tracker.import_profiles(profiles);
9432 }
9433
9434 pub async fn prepare_speech_runtime(&self) -> Result<PathBuf, InferenceError> {
9457 match self.ensure_speech_runtime().await {
9458 Ok(runtime) => Ok(runtime.root),
9459 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
9460 Err(err) => {
9461 let root = speech_runtime_root_from_models_dir(&self.config.models_dir);
9462 tracing::warn!(
9463 error = %err,
9464 root = %root.display(),
9465 "managed speech runtime could not be provisioned; native MLX \
9466 backends still cover the default local models, but catalogued \
9467 checkpoints they cannot load will be unavailable"
9468 );
9469 std::fs::create_dir_all(&root)?;
9470 Ok(root)
9471 }
9472 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
9473 Err(err) => Err(err),
9474 }
9475 }
9476
9477 pub fn set_speech_policy(&mut self, policy: SpeechPolicy) {
9479 self.speech_policy = policy;
9480 }
9481
9482 pub fn set_routing_config(&mut self, config: RoutingConfig) {
9483 self.adaptive_router.set_config(config);
9484 }
9485
9486 pub async fn install_curated_speech(
9488 &mut self,
9489 ) -> Result<Vec<SpeechInstallReport>, InferenceError> {
9490 if let Err(error) = self.prepare_speech_runtime().await {
9503 tracing::warn!(
9504 %error,
9505 "managed speech runtime could not be provisioned; installing the \
9506 models that do not depend on it"
9507 );
9508 }
9509 let schemas = self.list_schemas();
9510 let mut repos = Vec::new();
9511 for schema in &schemas {
9512 if !schema.is_mlx() || !schema.tags.iter().any(|tag| tag == "speech") {
9513 continue;
9514 }
9515 if !schema.available {
9521 continue;
9522 }
9523 if let ModelSource::Mlx { hf_repo, .. } = &schema.source {
9524 if !repos.iter().any(|existing: &String| existing == hf_repo) {
9525 repos.push(hf_repo.clone());
9526 }
9527 }
9528 }
9529
9530 let mut installed = Vec::new();
9531 for repo in repos {
9532 let (snapshot_path, files_downloaded) = download_hf_repo_snapshot(&repo).await?;
9533 let name = schemas
9534 .iter()
9535 .find(|schema| {
9536 matches!(&schema.source, ModelSource::Mlx { hf_repo, .. } if hf_repo == &repo)
9537 })
9538 .map(|schema| schema.name.clone())
9539 .unwrap_or_else(|| repo.clone());
9540 installed.push(SpeechInstallReport {
9541 name,
9542 hf_repo: repo,
9543 snapshot_path,
9544 files_downloaded,
9545 });
9546 }
9547
9548 for schema in &schemas {
9552 if !schema.tags.iter().any(|tag| tag == "speech") {
9553 continue;
9554 }
9555 if let ModelSource::WhisperCpp { model } = &schema.source {
9556 let model = model.clone();
9557 let name = schema.name.clone();
9558 let path = tokio::task::spawn_blocking(move || car_whisper::ensure_model(&model))
9559 .await
9560 .map_err(|e| InferenceError::InferenceFailed(format!("whisper join: {e}")))?
9561 .map_err(|e| {
9562 InferenceError::InferenceFailed(format!("whisper model fetch: {e}"))
9563 })?;
9564 installed.push(SpeechInstallReport {
9565 name,
9566 hf_repo: "ggerganov/whisper.cpp".to_string(),
9567 snapshot_path: path,
9568 files_downloaded: 1,
9569 });
9570 }
9571 }
9572
9573 self.unified_registry.refresh_availability();
9574 Ok(installed)
9575 }
9576
9577 pub fn speech_health(&self) -> SpeechHealthReport {
9579 let local_stt_default =
9580 self.speech_health_default_name(ModelCapability::SpeechToText, true, false);
9581 let local_tts_default =
9582 self.speech_health_default_name(ModelCapability::TextToSpeech, true, false);
9583 let remote_stt_default =
9584 self.speech_health_default_name(ModelCapability::SpeechToText, false, true);
9585 let remote_tts_default =
9586 self.speech_health_default_name(ModelCapability::TextToSpeech, false, true);
9587
9588 let mut local_models = Vec::new();
9589 let mut remote_models = Vec::new();
9590 for schema in self.list_schemas() {
9591 let capability = if schema.has_capability(ModelCapability::SpeechToText) {
9592 Some(ModelCapability::SpeechToText)
9593 } else if schema.has_capability(ModelCapability::TextToSpeech) {
9594 Some(ModelCapability::TextToSpeech)
9595 } else {
9596 None
9597 };
9598 let Some(capability) = capability else {
9599 continue;
9600 };
9601
9602 let selected_by_default = local_stt_default
9603 .as_ref()
9604 .is_some_and(|name| name == &schema.name)
9605 || local_tts_default
9606 .as_ref()
9607 .is_some_and(|name| name == &schema.name)
9608 || remote_stt_default
9609 .as_ref()
9610 .is_some_and(|name| name == &schema.name)
9611 || remote_tts_default
9612 .as_ref()
9613 .is_some_and(|name| name == &schema.name);
9614
9615 let health = SpeechModelHealth {
9616 id: schema.id.clone(),
9617 name: schema.name.clone(),
9618 provider: schema.provider.clone(),
9619 capability,
9620 is_local: schema.is_local(),
9621 available: schema.available,
9622 cached: speech_model_cached(&schema),
9623 selected_by_default,
9624 source: speech_model_source_label(&schema),
9625 };
9626 if schema.is_local() {
9627 local_models.push(health);
9628 } else {
9629 remote_models.push(health);
9630 }
9631 }
9632
9633 let runtime = {
9640 let rt =
9641 SpeechRuntime::new(speech_runtime_root_from_models_dir(&self.config.models_dir));
9642 SpeechRuntimeHealth {
9643 root: rt.root.clone(),
9644 installed: rt.is_ready(),
9645 python: rt.python.clone(),
9646 stt_command: rt.stt_program.clone(),
9647 tts_command: rt.tts_program.clone(),
9648 configured_python: std::env::var("CAR_SPEECH_PYTHON")
9649 .ok()
9650 .filter(|value| !value.trim().is_empty()),
9651 detected_python: detect_speech_python(),
9652 }
9653 };
9654
9655 SpeechHealthReport {
9656 runtime,
9657 local_models,
9658 remote_models,
9659 elevenlabs_configured: crate::tasks::transcribe::provider_configured_for_passive_status(
9663 "ELEVENLABS_API_KEY",
9664 )
9665 || crate::tasks::synthesize::provider_configured_for_passive_status(
9666 "ELEVENLABS_API_KEY",
9667 ),
9668 prefer_local: self.speech_policy.prefer_local,
9669 allow_remote_fallback: self.speech_policy.allow_remote_fallback,
9670 preferred_local_stt: self.speech_policy.preferred_local_stt.clone(),
9671 preferred_local_tts: self.speech_policy.preferred_local_tts.clone(),
9672 preferred_remote_stt: self.speech_policy.preferred_remote_stt.clone(),
9673 preferred_remote_tts: self.speech_policy.preferred_remote_tts.clone(),
9674 local_stt_default,
9675 local_tts_default,
9676 remote_stt_default,
9677 remote_tts_default,
9678 }
9679 }
9680
9681 pub async fn model_health(&self) -> ModelHealthReport {
9684 let schemas = self.list_schemas();
9685 let total_models = schemas.len();
9686 let available_models = schemas
9687 .iter()
9688 .filter(|schema| schema.available_now())
9689 .count();
9690 let local_models = schemas.iter().filter(|schema| schema.is_local()).count();
9691 let remote_models = total_models.saturating_sub(local_models);
9692
9693 let defaults = vec![
9694 self.model_default_health(
9695 ModelCapability::Generate,
9696 self.preferred_model_for_capability(ModelCapability::Generate)
9697 .unwrap_or(&self.config.generation_model),
9698 ),
9699 self.model_default_health(
9700 ModelCapability::Embed,
9701 self.preferred_model_for_capability(ModelCapability::Embed)
9702 .unwrap_or(&self.config.embedding_model),
9703 ),
9704 self.model_default_health(
9705 ModelCapability::Classify,
9706 self.preferred_model_for_capability(ModelCapability::Classify)
9707 .unwrap_or(&self.config.classification_model),
9708 ),
9709 ];
9710
9711 let mut providers = std::collections::BTreeMap::new();
9712 for schema in &schemas {
9713 let entry =
9714 providers
9715 .entry(schema.provider.clone())
9716 .or_insert_with(|| ProviderAccumulator {
9717 configured: false,
9718 local_models: 0,
9719 remote_models: 0,
9720 available_models: 0,
9721 capabilities: std::collections::HashSet::new(),
9722 });
9723
9724 entry.configured |= model_source_configured(schema);
9725 if schema.is_local() {
9726 entry.local_models += 1;
9727 } else {
9728 entry.remote_models += 1;
9729 }
9730 if schema.available_now() {
9731 entry.available_models += 1;
9732 }
9733 for capability in &schema.capabilities {
9734 entry.capabilities.insert(*capability);
9735 }
9736 }
9737
9738 let providers = providers
9739 .into_iter()
9740 .map(|(provider, acc)| ModelProviderHealth {
9741 provider,
9742 configured: acc.configured,
9743 local_models: acc.local_models,
9744 remote_models: acc.remote_models,
9745 available_models: acc.available_models,
9746 capabilities: sort_capabilities(acc.capabilities.into_iter().collect()),
9747 })
9748 .collect();
9749
9750 let capabilities = all_model_capabilities()
9751 .into_iter()
9752 .map(|capability| {
9753 let relevant: Vec<&ModelSchema> = schemas
9754 .iter()
9755 .filter(|schema| schema.has_capability(capability))
9756 .collect();
9757 let available: Vec<&ModelSchema> = relevant
9758 .iter()
9759 .copied()
9760 .filter(|schema| schema.available_now())
9761 .collect();
9762 ModelCapabilityHealth {
9763 capability,
9764 total_models: relevant.len(),
9765 available_models: available.len(),
9766 local_available_models: available
9767 .iter()
9768 .filter(|schema| schema.is_local())
9769 .count(),
9770 remote_available_models: available
9771 .iter()
9772 .filter(|schema| !schema.is_local())
9773 .count(),
9774 }
9775 })
9776 .collect();
9777
9778 let routing = self.routing_scenarios().await;
9779 let routing_config = self.adaptive_router.config().clone();
9780 let benchmark_priors =
9781 load_benchmark_prior_health(&self.config.state_models_dir(), &schemas);
9782
9783 ModelHealthReport {
9784 total_models,
9785 available_models,
9786 local_models,
9787 remote_models,
9788 defaults,
9789 providers,
9790 capabilities,
9791 routing_prefer_local: routing_config.prefer_local,
9792 routing_quality_first_cold_start: routing_config.quality_first_cold_start,
9793 routing_min_observations: routing_config.min_observations,
9794 routing_bootstrap_min_task_observations: routing_config.bootstrap_min_task_observations,
9795 routing_bootstrap_quality_floor: routing_config.bootstrap_quality_floor,
9796 routing_quality_weight: routing_config.quality_weight,
9797 routing_latency_weight: routing_config.latency_weight,
9798 routing_cost_weight: routing_config.cost_weight,
9799 routing_scenarios: routing,
9800 benchmark_priors,
9801 speech: self.speech_health(),
9802 }
9803 }
9804
9805 async fn routing_scenarios(&self) -> Vec<RoutingScenarioHealth> {
9806 let tracker = self.outcome_tracker.read().await;
9807 let config = self.adaptive_router.config().clone();
9808 let scenarios = [
9809 (
9810 "interactive_text",
9811 "Summarize the benefits of local-first AI routing in two sentences.",
9812 "text",
9813 RoutingWorkload::Interactive,
9814 false,
9815 false,
9816 ),
9817 (
9818 "background_code",
9819 "Write a Python function named fibonacci(n) that returns the nth Fibonacci number.",
9820 "code",
9821 RoutingWorkload::Background,
9822 false,
9823 false,
9824 ),
9825 (
9826 "interactive_tool_use",
9827 "Use the provided weather tool to get the weather for Boston.",
9828 "tool_use",
9829 RoutingWorkload::Interactive,
9830 true,
9831 false,
9832 ),
9833 (
9834 "interactive_vision",
9835 "What is in this image? Answer in one word.",
9836 "vision",
9837 RoutingWorkload::Interactive,
9838 false,
9839 true,
9840 ),
9841 ];
9842
9843 let routing_registry = self.catalog_registry_snapshot();
9850
9851 scenarios
9852 .into_iter()
9853 .map(
9854 |(name, prompt, task_family, workload, has_tools, has_vision)| {
9855 let decision = self.adaptive_router.route_context_aware(
9856 prompt,
9857 0,
9858 &routing_registry,
9859 &tracker,
9860 has_tools,
9861 has_vision,
9862 workload,
9863 );
9864 let quality_first_cold_start = if has_tools || has_vision {
9865 config.quality_first_cold_start
9866 } else if task_family == "code"
9867 && matches!(workload, RoutingWorkload::Background)
9868 {
9869 false
9870 } else {
9871 config.quality_first_cold_start
9872 };
9873 RoutingScenarioHealth {
9874 name: name.to_string(),
9875 task_family: task_family.to_string(),
9876 workload,
9877 has_tools,
9878 has_vision,
9879 prefer_local: if task_family == "speech" {
9880 self.speech_policy.prefer_local
9881 } else {
9882 config.prefer_local
9883 },
9884 quality_first_cold_start,
9885 bootstrap_min_task_observations: config.bootstrap_min_task_observations,
9886 bootstrap_quality_floor: config.bootstrap_quality_floor,
9887 model_id: decision.model_id,
9888 model_name: decision.model_name,
9889 reason: decision.reason,
9890 strategy: decision.strategy,
9891 }
9892 },
9893 )
9894 .collect()
9895 }
9896
9897 pub async fn smoke_test_speech(
9899 &self,
9900 local: bool,
9901 remote: bool,
9902 ) -> Result<SpeechSmokeReport, InferenceError> {
9903 let mut report = SpeechSmokeReport::default();
9904
9905 if local {
9906 let tts = self
9907 .preferred_speech_schema(ModelCapability::TextToSpeech, true, false)
9908 .ok_or_else(|| {
9909 InferenceError::InferenceFailed(
9910 "no local text-to-speech model available".into(),
9911 )
9912 })?;
9913 let stt = self
9914 .preferred_speech_schema(ModelCapability::SpeechToText, true, false)
9915 .ok_or_else(|| {
9916 InferenceError::InferenceFailed(
9917 "no local speech-to-text model available".into(),
9918 )
9919 })?;
9920 report.local = Some(
9921 self.run_speech_smoke_path("local", &tts, &stt, "Testing CAR local speech path.")
9922 .await?,
9923 );
9924 } else {
9925 report.skipped.push("local".to_string());
9926 }
9927
9928 if remote {
9929 let tts = self
9930 .preferred_speech_schema(ModelCapability::TextToSpeech, false, true)
9931 .ok_or_else(|| {
9932 InferenceError::InferenceFailed(
9933 "no remote text-to-speech model available".into(),
9934 )
9935 })?;
9936 let stt = self
9937 .preferred_speech_schema(ModelCapability::SpeechToText, false, true)
9938 .ok_or_else(|| {
9939 InferenceError::InferenceFailed(
9940 "no remote speech-to-text model available".into(),
9941 )
9942 })?;
9943 report.remote = Some(
9944 self.run_speech_smoke_path("remote", &tts, &stt, "Testing CAR remote speech path.")
9945 .await?,
9946 );
9947 } else {
9948 report.skipped.push("remote".to_string());
9949 }
9950
9951 Ok(report)
9952 }
9953
9954 fn speech_candidates(
9955 &self,
9956 capability: ModelCapability,
9957 explicit: Option<&str>,
9958 ) -> Result<Vec<ModelSchema>, InferenceError> {
9959 if let Some(model) = explicit {
9960 let schema = self
9961 .unified_registry
9962 .get(model)
9963 .or_else(|| self.unified_registry.find_by_name(model))
9964 .cloned()
9965 .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?;
9966 if !schema.has_capability(capability) {
9967 return Err(InferenceError::InferenceFailed(format!(
9968 "model {} does not support {:?}",
9969 schema.name, capability
9970 )));
9971 }
9972 return Ok(vec![schema]);
9973 }
9974
9975 let mut candidates: Vec<ModelSchema> = self
9976 .unified_registry
9977 .query(&ModelFilter {
9978 capabilities: vec![capability],
9979 ..Default::default()
9980 })
9981 .into_iter()
9982 .cloned()
9983 .collect();
9984
9985 if candidates.is_empty() {
9986 return Err(InferenceError::InferenceFailed(format!(
9987 "no models registered for capability {:?}",
9988 capability
9989 )));
9990 }
9991
9992 candidates.sort_by_key(|model| self.speech_sort_key(capability, model));
9993 if !self.speech_policy.allow_remote_fallback
9994 && candidates.iter().any(|model| model.is_local())
9995 {
9996 candidates.retain(|model| model.is_local());
9997 }
9998
9999 Ok(candidates)
10000 }
10001
10002 #[allow(dead_code)] fn resolve_external_hf_repo(
10008 &self,
10009 explicit: Option<&str>,
10010 capability: ModelCapability,
10011 ) -> Option<String> {
10012 let id = explicit?;
10013 let schema = self
10014 .unified_registry
10015 .get(id)
10016 .or_else(|| self.unified_registry.find_by_name(id))?;
10017 if !schema.has_capability(capability) {
10018 return Some(id.to_string());
10019 }
10020 if let ModelSource::Mlx { hf_repo, .. } = &schema.source {
10021 return Some(hf_repo.clone());
10022 }
10023 Some(id.to_string())
10024 }
10025
10026 fn media_generation_candidates(
10027 &self,
10028 capability: ModelCapability,
10029 explicit: Option<&str>,
10030 ) -> Result<Vec<ModelSchema>, InferenceError> {
10031 if let Some(model) = explicit {
10032 let schema = self
10033 .unified_registry
10034 .get(model)
10035 .or_else(|| self.unified_registry.find_by_name(model))
10036 .cloned()
10037 .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?;
10038 if !schema.has_capability(capability) {
10039 return Err(InferenceError::InferenceFailed(format!(
10040 "model {} does not support {:?}",
10041 schema.name, capability
10042 )));
10043 }
10044 return Ok(vec![schema]);
10045 }
10046
10047 let mut candidates: Vec<ModelSchema> = self
10048 .unified_registry
10049 .query(&ModelFilter {
10050 capabilities: vec![capability],
10051 local_only: true,
10052 ..Default::default()
10053 })
10054 .into_iter()
10055 .cloned()
10056 .collect();
10057 candidates.sort_by_key(|schema| (!schema.available, schema.size_mb()));
10058 if candidates.is_empty() {
10059 return Err(InferenceError::InferenceFailed(format!(
10060 "no models registered for capability {:?}",
10061 capability
10062 )));
10063 }
10064 Ok(candidates)
10065 }
10066
10067 fn preferred_speech_schema(
10068 &self,
10069 capability: ModelCapability,
10070 local_only: bool,
10071 remote_only: bool,
10072 ) -> Option<ModelSchema> {
10073 let available_only = remote_only;
10074 let mut candidates: Vec<ModelSchema> = self
10075 .unified_registry
10076 .query(&ModelFilter {
10077 capabilities: vec![capability],
10078 available_only,
10079 ..Default::default()
10080 })
10081 .into_iter()
10082 .filter(|schema| {
10083 (!local_only || schema.is_local()) && (!remote_only || schema.is_remote())
10084 })
10085 .cloned()
10086 .collect();
10087 candidates.sort_by_key(|model| self.speech_sort_key(capability, model));
10088 candidates.into_iter().next()
10089 }
10090
10091 fn speech_health_default_name(
10092 &self,
10093 capability: ModelCapability,
10094 local_only: bool,
10095 remote_only: bool,
10096 ) -> Option<String> {
10097 let preferred = match capability {
10098 ModelCapability::SpeechToText if local_only => {
10099 self.speech_policy.preferred_local_stt.as_ref()
10100 }
10101 ModelCapability::SpeechToText if remote_only => {
10102 self.speech_policy.preferred_remote_stt.as_ref()
10103 }
10104 ModelCapability::TextToSpeech if local_only => {
10105 self.speech_policy.preferred_local_tts.as_ref()
10106 }
10107 ModelCapability::TextToSpeech if remote_only => {
10108 self.speech_policy.preferred_remote_tts.as_ref()
10109 }
10110 _ => None,
10111 };
10112
10113 preferred
10114 .filter(|name| {
10115 self.unified_registry.list().iter().any(|schema| {
10116 schema.name == **name
10117 && schema.has_capability(capability)
10118 && (!local_only || schema.is_local())
10119 && (!remote_only || schema.is_remote())
10120 })
10121 })
10122 .cloned()
10123 .or_else(|| {
10124 self.preferred_speech_schema(capability, local_only, remote_only)
10125 .map(|schema| schema.name)
10126 })
10127 }
10128
10129 fn model_default_health(
10130 &self,
10131 capability: ModelCapability,
10132 configured_model: &str,
10133 ) -> ModelDefaultHealth {
10134 let schema = self
10135 .unified_registry
10136 .find_by_name(configured_model)
10137 .or_else(|| self.unified_registry.get(configured_model));
10138
10139 ModelDefaultHealth {
10140 capability,
10141 configured_model: configured_model.to_string(),
10142 available: schema.is_some_and(ModelSchema::available_now),
10143 is_local: schema.is_some_and(ModelSchema::is_local),
10144 provider: schema.map(|model| model.provider.clone()),
10145 }
10146 }
10147
10148 fn speech_sort_key(
10149 &self,
10150 capability: ModelCapability,
10151 model: &ModelSchema,
10152 ) -> (u8, u8, u8, u8, u64, u64) {
10153 let policy_preference = match capability {
10154 ModelCapability::SpeechToText if model.is_local() => {
10155 self.speech_policy.preferred_local_stt.as_ref()
10156 }
10157 ModelCapability::SpeechToText => self.speech_policy.preferred_remote_stt.as_ref(),
10158 ModelCapability::TextToSpeech if model.is_local() => {
10159 self.speech_policy.preferred_local_tts.as_ref()
10160 }
10161 ModelCapability::TextToSpeech => self.speech_policy.preferred_remote_tts.as_ref(),
10162 _ => None,
10163 };
10164 let local_rank = if self.speech_policy.prefer_local {
10165 if model.is_local() {
10166 0
10167 } else {
10168 1
10169 }
10170 } else if model.is_remote() {
10171 0
10172 } else {
10173 1
10174 };
10175 let availability_rank = if model.available {
10176 0
10177 } else if model.is_local() {
10178 1
10179 } else {
10180 2
10181 };
10182 let policy_rank: u8 = if policy_preference.is_some_and(|preferred| preferred == &model.name)
10183 {
10184 0
10185 } else {
10186 1
10187 };
10188 let speech_rank = match capability {
10189 ModelCapability::TextToSpeech => {
10204 if model.name == "Kokoro-82M-bf16" {
10205 0
10206 } else if model.name == "Kokoro-82M-6bit" {
10207 1
10208 } else if model.name == "Qwen3-TTS-12Hz-1.7B-Base-5bit" {
10209 3
10212 } else {
10213 2
10214 }
10215 }
10216 ModelCapability::SpeechToText => {
10217 if model.name == "Parakeet-TDT-0.6B-v3-MLX" {
10218 0
10219 } else {
10220 1
10221 }
10222 }
10223 _ => 0,
10224 };
10225 let latency_rank = model.performance.latency_p50_ms.unwrap_or(u64::MAX);
10226 let size_rank = model.cost.size_mb.unwrap_or(u64::MAX);
10227 (
10228 local_rank,
10229 availability_rank,
10230 policy_rank,
10231 speech_rank,
10232 latency_rank,
10233 size_rank,
10234 )
10235 }
10236
10237 async fn run_speech_smoke_path(
10238 &self,
10239 path: &str,
10240 tts: &ModelSchema,
10241 stt: &ModelSchema,
10242 text: &str,
10243 ) -> Result<SpeechSmokePathReport, InferenceError> {
10244 let work_dir = temp_work_dir(&format!("speech-smoke-{path}"))?;
10245 let audio_path = work_dir.join(format!("{path}.wav"));
10246 let synth = self
10247 .synthesize(SynthesizeRequest {
10248 text: text.to_string(),
10249 model: Some(tts.name.clone()),
10250 voice: default_speech_voice(tts),
10251 language: Some("en".to_string()),
10252 output_path: Some(audio_path.display().to_string()),
10253 ..SynthesizeRequest::default()
10254 })
10255 .await?;
10256 let transcript = self
10257 .transcribe(TranscribeRequest {
10258 audio_path: synth.audio_path.clone(),
10259 model: Some(stt.name.clone()),
10260 language: Some("en".to_string()),
10261 prompt: None,
10262 timestamps: false,
10263 })
10264 .await?;
10265
10266 Ok(SpeechSmokePathReport {
10267 path: path.to_string(),
10268 tts_model: synth.model_used.unwrap_or_else(|| tts.name.clone()),
10269 stt_model: transcript.model_used.unwrap_or_else(|| stt.name.clone()),
10270 audio_path: PathBuf::from(synth.audio_path),
10271 transcript: transcript.text,
10272 })
10273 }
10274
10275 async fn ensure_speech_runtime(&self) -> Result<SpeechRuntime, InferenceError> {
10276 let mut guard = self.speech_runtime.lock().await;
10277 if let Some(runtime) = guard.as_ref() {
10278 if runtime.is_ready() {
10279 return Ok(runtime.clone());
10280 }
10281 }
10282
10283 let runtime =
10284 SpeechRuntime::new(speech_runtime_root_from_models_dir(&self.config.models_dir));
10285 if !runtime.is_ready() {
10286 bootstrap_speech_runtime(&runtime).await?;
10287 }
10288 if !runtime.is_ready() {
10289 return Err(InferenceError::InferenceFailed(format!(
10290 "managed speech runtime is not ready at {}",
10291 runtime.root.display()
10292 )));
10293 }
10294
10295 *guard = Some(runtime.clone());
10296 Ok(runtime)
10297 }
10298
10299 async fn transcribe_whisper(
10308 &self,
10309 schema: &ModelSchema,
10310 model: &str,
10311 req: &TranscribeRequest,
10312 reservation: Option<&mut resource_policy::LocalLoadReservation>,
10313 ) -> Result<TranscribeResult, InferenceError> {
10314 let model = model.to_string();
10315 let model_path = car_whisper::ensure_model(&model)
10316 .map_err(|e| InferenceError::InferenceFailed(format!("whisper download: {e}")))?;
10317 let measured_bytes = backend_cache::estimate_model_size(&model_path);
10318 let reservation = reservation.ok_or_else(|| {
10319 InferenceError::InferenceFailed("local Whisper path missing admission".into())
10320 })?;
10321 reservation
10322 .reconcile_measured_weights(measured_bytes)
10323 .map_err(InferenceError::from)?;
10324 let detached_lease = reservation.detached_lease();
10325 let language = req.language.clone().unwrap_or_else(|| "auto".to_string());
10327 let audio_path = std::path::PathBuf::from(&req.audio_path);
10328 let name = schema.name.clone();
10329 let req_language = req.language.clone();
10330 let text =
10331 run_admitted_blocking(detached_lease, move || -> Result<String, InferenceError> {
10332 let stt = car_whisper::WhisperStt::load_from_path(&model_path, &language)
10334 .map_err(|e| InferenceError::InferenceFailed(format!("whisper load: {e}")))?;
10335 stt.transcribe_file(&audio_path).map_err(|e| {
10336 InferenceError::InferenceFailed(format!("whisper transcribe: {e}"))
10337 })
10338 })
10339 .await
10340 .map_err(|e| InferenceError::InferenceFailed(format!("whisper join: {e}")))??;
10341 Ok(TranscribeResult::text_only(text, Some(name), req_language))
10342 }
10343
10344 async fn transcribe_local_mlx(
10345 &self,
10346 schema: &ModelSchema,
10347 req: &TranscribeRequest,
10348 reservation: Option<&mut resource_policy::LocalLoadReservation>,
10349 ) -> Result<TranscribeResult, InferenceError> {
10350 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
10351 let reservation = reservation.ok_or_else(|| {
10352 InferenceError::InferenceFailed("local STT path missing admission".into())
10353 })?;
10354 reservation
10355 .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
10356 .map_err(InferenceError::from)?;
10357 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
10359 {
10360 let parakeet = match backend::mlx_parakeet::ParakeetBackend::load(&model_dir) {
10365 Ok(p) => p,
10366 Err(native_err) => {
10367 tracing::info!(
10368 model = %schema.name,
10369 error = %native_err,
10370 "native MLX STT backend can't load this model; \
10371 falling back to the managed mlx-audio runtime"
10372 );
10373 return self
10374 .transcribe_via_speech_runtime(schema, req, reservation.detached_lease())
10375 .await
10376 .map_err(|runtime_err| {
10377 InferenceError::InferenceFailed(format!(
10378 "native MLX backend failed ({native_err}); \
10379 mlx-audio runtime fallback also failed ({runtime_err}). \
10380 Install the runtime with `car speech install`."
10381 ))
10382 });
10383 }
10384 };
10385 let (text, words) = if req.timestamps {
10387 parakeet
10388 .transcribe_detailed(Path::new(&req.audio_path))
10389 .map_err(|e| InferenceError::InferenceFailed(format!("native STT: {e}")))?
10390 } else {
10391 let t = parakeet
10392 .transcribe(Path::new(&req.audio_path))
10393 .map_err(|e| InferenceError::InferenceFailed(format!("native STT: {e}")))?;
10394 (t, Vec::new())
10395 };
10396 Ok(TranscribeResult {
10397 text,
10398 model_used: Some(schema.name.clone()),
10399 language: req.language.clone(),
10400 words,
10401 routing_explanation: None,
10402 })
10403 }
10404
10405 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
10407 {
10408 self.transcribe_via_speech_runtime(schema, req, reservation.detached_lease())
10409 .await
10410 }
10411 }
10412
10413 async fn transcribe_via_speech_runtime(
10420 &self,
10421 schema: &ModelSchema,
10422 req: &TranscribeRequest,
10423 detached_lease: resource_policy::DetachedLocalLease,
10424 ) -> Result<TranscribeResult, InferenceError> {
10425 {
10426 let runtime = self.ensure_speech_runtime().await?;
10427 let hf_repo = match &schema.source {
10428 ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
10429 _ => {
10430 return Err(InferenceError::InferenceFailed(format!(
10431 "speech runtime needs an MLX model repo; {} is not one",
10432 schema.id
10433 )))
10434 }
10435 };
10436 let output_dir = temp_work_dir("stt")?;
10437 let output_prefix = output_dir.join("transcript");
10438 let mut args = vec![
10439 "--model".to_string(),
10440 hf_repo,
10441 "--audio".to_string(),
10442 req.audio_path.clone(),
10443 "--output-path".to_string(),
10444 output_prefix.display().to_string(),
10445 "--format".to_string(),
10446 "json".to_string(),
10447 ];
10448 if let Some(language) = &req.language {
10449 args.push("--language".to_string());
10450 args.push(normalize_lang_code(language));
10451 }
10452 if let Some(prompt) = &req.prompt {
10453 args.push("--context".to_string());
10454 args.push(prompt.clone());
10455 }
10456 if req.timestamps {
10457 args.push("--verbose".to_string());
10458 }
10459
10460 let output =
10461 run_mlx_audio_command(&runtime, "stt.generate", &args, detached_lease).await?;
10462 let text = read_transcription_result(&output_prefix)?
10463 .or_else(|| extract_text_from_payload(&output.stdout))
10464 .ok_or_else(|| {
10465 InferenceError::InferenceFailed(format!(
10466 "mlx-audio transcription returned no text: {}",
10467 output.stderr
10468 ))
10469 })?;
10470
10471 Ok(TranscribeResult {
10472 text,
10473 model_used: Some(schema.name.clone()),
10474 language: req.language.clone(),
10475 words: Vec::new(),
10476 routing_explanation: None,
10477 })
10478 }
10479 }
10480
10481 async fn synthesize_local_mlx(
10482 &self,
10483 schema: &ModelSchema,
10484 req: &SynthesizeRequest,
10485 reservation: Option<&mut resource_policy::LocalLoadReservation>,
10486 ) -> Result<SynthesizeResult, InferenceError> {
10487 let requested = req.requested_advanced_controls();
10492 let repo_supports_advanced = match &schema.source {
10493 ModelSource::Mlx { hf_repo, .. } => hf_repo.to_ascii_lowercase().contains("qwen3-tts"),
10494 _ => false,
10495 };
10496 if !requested.is_empty() && !repo_supports_advanced {
10497 if req.strict_capabilities {
10498 return Err(InferenceError::InferenceFailed(format!(
10499 "model {name} does not support Qwen3-TTS advanced controls {requested:?}; \
10500 route to a Qwen3-TTS model or set strict_capabilities = false to degrade",
10501 name = schema.name,
10502 )));
10503 }
10504 tracing::warn!(
10505 model = %schema.name,
10506 fields = ?requested,
10507 "Qwen3-TTS advanced controls set on non-Qwen3-TTS backend — ignored \
10508 (set strict_capabilities=true to error instead)"
10509 );
10510 }
10511
10512 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
10513 let reservation = reservation.ok_or_else(|| {
10514 InferenceError::InferenceFailed("local TTS path missing admission".into())
10515 })?;
10516 reservation
10517 .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
10518 .map_err(InferenceError::from)?;
10519
10520 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
10522 {
10523 if repo_supports_advanced && !requested.is_empty() {
10529 if req.strict_capabilities {
10530 return Err(InferenceError::InferenceFailed(format!(
10531 "native MLX TTS backend does not yet implement Qwen3-TTS advanced \
10532 controls {requested:?}; run on non-Apple-Silicon to use the Python \
10533 mlx-audio fallback, or set strict_capabilities = false"
10534 )));
10535 }
10536 tracing::warn!(
10537 model = %schema.name,
10538 fields = ?requested,
10539 "Qwen3-TTS advanced controls are not yet implemented in the native MLX TTS \
10540 backend; synthesizing without cloning/voice-design"
10541 );
10542 }
10543 let size = backend_cache::estimate_model_size(&model_dir);
10544 let cache_key = reservation.model_id().to_string();
10545 let (handle, retention) = match Self::load_backend_healing(
10547 &cache_key,
10548 model_dir,
10549 &self.kokoro_cache,
10550 size,
10551 reservation,
10552 backend::mlx_kokoro::KokoroBackend::load,
10553 || self.unified_registry.redownload_local(&schema.id),
10554 )
10555 .await
10556 {
10557 Ok(handle) => handle,
10558 Err(native_err) => {
10566 tracing::info!(
10567 model = %schema.name,
10568 error = %native_err,
10569 "native MLX TTS backend can't load this model; \
10570 falling back to the managed mlx-audio runtime"
10571 );
10572 return self
10573 .synthesize_via_speech_runtime(schema, req, reservation.detached_lease())
10574 .await
10575 .map_err(|runtime_err| {
10576 InferenceError::InferenceFailed(format!(
10577 "native MLX backend failed ({native_err}); \
10578 mlx-audio runtime fallback also failed ({runtime_err}). \
10579 Install the runtime with `car speech install`."
10580 ))
10581 });
10582 }
10583 };
10584
10585 let output_path = req.output_path.clone().unwrap_or_else(|| {
10586 let dir = std::env::temp_dir().join("car_tts");
10587 let _ = std::fs::create_dir_all(&dir);
10588 dir.join("output.wav").display().to_string()
10589 });
10590 let voice = req.voice.as_deref().unwrap_or("af_heart").to_string();
10591 let text = req.text.clone();
10592 let detached_lease = (retention == backend_cache::BackendRetention::Transient)
10593 .then(|| reservation.detached_lease());
10594 let device_guard = Self::mlx_device_lock().lock_owned().await;
10600 let op = tokio::task::spawn_blocking(move || -> Result<PathBuf, InferenceError> {
10601 let _detached_lease = detached_lease;
10602 let _device_guard = device_guard;
10603 let mut guard = handle.lock().map_err(|_| {
10604 InferenceError::InferenceFailed("kokoro backend mutex poisoned".into())
10605 })?;
10606 guard
10607 .synthesize(&text, Some(&voice), Path::new(&output_path))
10608 .map_err(|e| InferenceError::InferenceFailed(format!("native TTS: {e}")))
10609 })
10610 .await
10611 .map_err(|e| InferenceError::InferenceFailed(format!("kokoro task join: {e}")))??;
10612
10613 let final_path =
10614 materialize_audio_output(&op, req.output_path.as_deref(), &req.format)?;
10615 Ok(SynthesizeResult {
10616 audio_path: final_path.display().to_string(),
10617 media_type: media_type_for_format(&req.format),
10618 model_used: Some(schema.name.clone()),
10619 voice_used: req.voice.clone(),
10620 routing_explanation: None,
10621 })
10622 }
10623
10624 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
10626 {
10627 self.synthesize_via_speech_runtime(schema, req, reservation.detached_lease())
10628 .await
10629 }
10630 }
10631
10632 async fn synthesize_via_speech_runtime(
10645 &self,
10646 schema: &ModelSchema,
10647 req: &SynthesizeRequest,
10648 detached_lease: resource_policy::DetachedLocalLease,
10649 ) -> Result<SynthesizeResult, InferenceError> {
10650 let runtime = self.ensure_speech_runtime().await?;
10651 let primary_hf_repo = match &schema.source {
10652 ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
10653 _ => {
10654 return Err(InferenceError::InferenceFailed(format!(
10655 "speech runtime needs an MLX model repo; {} is not one",
10656 schema.id
10657 )))
10658 }
10659 };
10660 let (produced, model_used) = match self
10661 .synthesize_local_mlx_repo(
10662 &runtime,
10663 &primary_hf_repo,
10664 schema.name.as_str(),
10665 req,
10666 detached_lease.clone(),
10667 )
10668 .await
10669 {
10670 Ok(result) => result,
10671 Err(primary_err)
10672 if primary_hf_repo == "mlx-community/Kokoro-82M-6bit"
10673 && kokoro_runtime_fallback_enabled() =>
10674 {
10675 let fallback_repo = "mlx-community/Kokoro-82M-bf16";
10676 let fallback_name = "Kokoro-82M-bf16";
10677 match self
10678 .synthesize_local_mlx_repo(
10679 &runtime,
10680 fallback_repo,
10681 fallback_name,
10682 req,
10683 detached_lease.clone(),
10684 )
10685 .await
10686 {
10687 Ok(result) => result,
10688 Err(fallback_err) => {
10689 return Err(InferenceError::InferenceFailed(format!(
10690 "{primary_err}; fallback {fallback_name} also failed: {fallback_err}"
10691 )));
10692 }
10693 }
10694 }
10695 Err(err) => return Err(err),
10696 };
10697 let final_path =
10698 materialize_audio_output(&produced, req.output_path.as_deref(), &req.format)?;
10699
10700 Ok(SynthesizeResult {
10701 audio_path: final_path.display().to_string(),
10702 media_type: media_type_for_format(&req.format),
10703 model_used: Some(model_used),
10704 voice_used: req.voice.clone(),
10705 routing_explanation: None,
10706 })
10707 }
10708
10709 async fn synthesize_local_mlx_repo(
10710 &self,
10711 runtime: &SpeechRuntime,
10712 hf_repo: &str,
10713 model_name: &str,
10714 req: &SynthesizeRequest,
10715 detached_lease: resource_policy::DetachedLocalLease,
10716 ) -> Result<(PathBuf, String), InferenceError> {
10717 let output_dir = temp_work_dir("tts")?;
10718 let mut args = vec![
10719 "--model".to_string(),
10720 hf_repo.to_string(),
10721 "--text".to_string(),
10722 req.text.clone(),
10723 "--output_path".to_string(),
10724 output_dir.display().to_string(),
10725 ];
10726 if let Some(voice) = &req.voice {
10727 args.push("--voice".to_string());
10728 args.push(voice.clone());
10729 }
10730 if let Some(speed) = req.speed {
10731 args.push("--speed".to_string());
10732 args.push(speed.to_string());
10733 }
10734 let repo_lower = hf_repo.to_ascii_lowercase();
10735 if repo_lower.contains("kokoro") {
10736 args.push("--lang_code".to_string());
10737 args.push(kokoro_lang_code(req.language.as_deref()).to_string());
10738 } else if let Some(language) = &req.language {
10739 args.push("--lang_code".to_string());
10740 args.push(normalize_lang_code(language));
10741 }
10742
10743 if repo_lower.contains("qwen3-tts") {
10749 if let Some(ref_audio) = &req.reference_audio_path {
10750 args.push("--ref_audio".to_string());
10751 args.push(ref_audio.clone());
10752 }
10753 if let Some(ref_text) = &req.reference_text {
10754 args.push("--ref_text".to_string());
10755 args.push(ref_text.clone());
10756 }
10757 if let Some(instruct) = &req.voice_instruction {
10758 args.push("--instruct".to_string());
10759 args.push(instruct.clone());
10760 }
10761 }
10762
10763 let output = if repo_lower.contains("kokoro") {
10764 let device = std::env::var("CAR_SPEECH_KOKORO_DEVICE")
10765 .or_else(|_| std::env::var("CAR_SPEECH_MLX_DEVICE"))
10766 .unwrap_or_else(|_| "cpu".to_string());
10767 let extra_env = vec![
10768 ("MLX_DEVICE".to_string(), device),
10770 ("PYTORCH_ENABLE_MPS_FALLBACK".to_string(), "1".to_string()),
10772 ];
10773 run_mlx_audio_command_with_env(
10774 runtime,
10775 "tts.generate",
10776 &args,
10777 &extra_env,
10778 detached_lease,
10779 )
10780 .await?
10781 } else {
10782 run_mlx_audio_command(runtime, "tts.generate", &args, detached_lease).await?
10783 };
10784 let produced = find_audio_file(&output_dir)?.ok_or_else(|| {
10785 let hint = if repo_lower.contains("kokoro") {
10786 ". Kokoro models may crash on GPU — try CAR_SPEECH_KOKORO_DEVICE=cpu or use the default Qwen3-TTS model"
10787 } else {
10788 ""
10789 };
10790 InferenceError::InferenceFailed(format!(
10791 "mlx-audio synthesis produced no audio file: {}{}",
10792 output.stderr, hint
10793 ))
10794 })?;
10795 Ok((produced, model_name.to_string()))
10796 }
10797
10798 async fn transcribe_elevenlabs(
10799 &self,
10800 schema: &ModelSchema,
10801 req: &TranscribeRequest,
10802 ) -> Result<TranscribeResult, InferenceError> {
10803 let (endpoint, api_key) = elevenlabs_auth(
10804 schema,
10805 crate::tasks::transcribe::resolve_provider_credential_for_request,
10806 )?;
10807 let file_name = Path::new(&req.audio_path)
10808 .file_name()
10809 .and_then(|f| f.to_str())
10810 .unwrap_or("audio.wav")
10811 .to_string();
10812 let audio_bytes = tokio::fs::read(&req.audio_path).await?;
10813 let file_part = Part::bytes(audio_bytes).file_name(file_name);
10814 let mut form = Form::new()
10815 .text("model_id", schema.name.clone())
10816 .part("file", file_part);
10817 if let Some(language) = &req.language {
10818 form = form.text("language_code", language.clone());
10819 }
10820
10821 let resp = self
10822 .remote_backend
10823 .client
10824 .post(format!(
10825 "{}/v1/speech-to-text",
10826 endpoint.trim_end_matches('/')
10827 ))
10828 .header("xi-api-key", api_key)
10829 .multipart(form)
10830 .send()
10831 .await
10832 .map_err(|e| {
10833 self.remote_backend
10834 .request_error("ElevenLabs STT request failed", &e)
10835 })?;
10836 let status = resp.status();
10837 let body = resp.text().await.map_err(|e| {
10838 InferenceError::InferenceFailed(format!("read ElevenLabs STT body: {e}"))
10839 })?;
10840 if !status.is_success() {
10841 return Err(InferenceError::InferenceFailed(format!(
10842 "ElevenLabs STT returned {status}: {body}"
10843 )));
10844 }
10845 let payload: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
10846 InferenceError::InferenceFailed(format!("parse ElevenLabs STT response: {e}"))
10847 })?;
10848 let text = payload
10849 .get("text")
10850 .and_then(|v| v.as_str())
10851 .map(str::to_string)
10852 .ok_or_else(|| {
10853 InferenceError::InferenceFailed("ElevenLabs STT response missing text".into())
10854 })?;
10855
10856 Ok(TranscribeResult {
10857 text,
10858 model_used: Some(schema.name.clone()),
10859 language: payload
10860 .get("language_code")
10861 .and_then(|v| v.as_str())
10862 .map(str::to_string),
10863 words: Vec::new(),
10864 routing_explanation: None,
10865 })
10866 }
10867
10868 async fn synthesize_elevenlabs(
10869 &self,
10870 schema: &ModelSchema,
10871 req: &SynthesizeRequest,
10872 ) -> Result<SynthesizeResult, InferenceError> {
10873 let requested = req.requested_advanced_controls();
10877 if !requested.is_empty() {
10878 if req.strict_capabilities {
10879 return Err(InferenceError::InferenceFailed(format!(
10880 "ElevenLabs backend does not support Qwen3-TTS advanced controls \
10881 {requested:?}; route to a Qwen3-TTS model or set strict_capabilities = false"
10882 )));
10883 }
10884 tracing::warn!(
10885 model = %schema.name,
10886 fields = ?requested,
10887 "Qwen3-TTS advanced controls ignored by ElevenLabs backend"
10888 );
10889 }
10890 let (endpoint, api_key) = elevenlabs_auth(
10891 schema,
10892 crate::tasks::synthesize::resolve_provider_credential_for_request,
10893 )?;
10894 let voice_id = req
10895 .voice
10896 .clone()
10897 .unwrap_or_else(|| "JBFqnCBsd6RMkjVDRZzb".to_string());
10898 let output_format = elevenlabs_output_format(&req.format);
10899 let url = format!(
10900 "{}/v1/text-to-speech/{}?output_format={}",
10901 endpoint.trim_end_matches('/'),
10902 voice_id,
10903 output_format
10904 );
10905
10906 let mut body = serde_json::json!({
10907 "text": req.text,
10908 "model_id": schema.name,
10909 });
10910 if let Some(language) = &req.language {
10911 body["language_code"] = serde_json::Value::String(language.clone());
10912 }
10913
10914 let resp = self
10915 .remote_backend
10916 .client
10917 .post(url)
10918 .header("xi-api-key", api_key)
10919 .header("Content-Type", "application/json")
10920 .json(&body)
10921 .send()
10922 .await
10923 .map_err(|e| {
10924 self.remote_backend
10925 .request_error("ElevenLabs TTS request failed", &e)
10926 })?;
10927 let status = resp.status();
10928 let audio = resp.bytes().await.map_err(|e| {
10929 InferenceError::InferenceFailed(format!("read ElevenLabs TTS body: {e}"))
10930 })?;
10931 if !status.is_success() {
10932 let err_body = String::from_utf8_lossy(&audio);
10933 return Err(InferenceError::InferenceFailed(format!(
10934 "ElevenLabs TTS returned {status}: {err_body}"
10935 )));
10936 }
10937
10938 let final_path = requested_or_temp_output(req.output_path.as_deref(), &req.format)?;
10939 ensure_parent_dir(&final_path)?;
10940 tokio::fs::write(&final_path, &audio).await?;
10941
10942 Ok(SynthesizeResult {
10943 audio_path: final_path.display().to_string(),
10944 media_type: media_type_for_format(&req.format),
10945 model_used: Some(schema.name.clone()),
10946 voice_used: Some(voice_id),
10947 routing_explanation: None,
10948 })
10949 }
10950}
10951
10952#[derive(Default)]
10953struct ProviderAccumulator {
10954 configured: bool,
10955 local_models: usize,
10956 remote_models: usize,
10957 available_models: usize,
10958 capabilities: std::collections::HashSet<ModelCapability>,
10959}
10960
10961struct CommandOutput {
10965 stdout: String,
10966 stderr: String,
10967}
10968
10969#[derive(Debug, Clone)]
10970struct SpeechRuntime {
10971 root: PathBuf,
10972 python: PathBuf,
10973 stt_program: PathBuf,
10974 tts_program: PathBuf,
10975}
10976
10977impl SpeechRuntime {
10978 fn new(root: PathBuf) -> Self {
10979 let python = managed_venv::interpreter(&root);
10980 let stt_program = managed_venv::venv_program(&root, "mlx_audio.stt.generate");
10981 let tts_program = managed_venv::venv_program(&root, "mlx_audio.tts.generate");
10982 Self {
10983 root,
10984 python,
10985 stt_program,
10986 tts_program,
10987 }
10988 }
10989
10990 fn is_ready(&self) -> bool {
10991 managed_venv::interpreter_healthy(&self.root)
10996 && self.stt_program.exists()
10997 && self.tts_program.exists()
10998 }
10999
11000 fn command_for(&self, subcommand: &str) -> Result<&Path, InferenceError> {
11001 match subcommand {
11002 "stt.generate" => Ok(&self.stt_program),
11003 "tts.generate" => Ok(&self.tts_program),
11004 _ => Err(InferenceError::InferenceFailed(format!(
11005 "unknown speech subcommand: {subcommand}"
11006 ))),
11007 }
11008 }
11009}
11010
11011async fn run_mlx_audio_command(
11012 runtime: &SpeechRuntime,
11013 subcommand: &str,
11014 args: &[String],
11015 detached_lease: resource_policy::DetachedLocalLease,
11016) -> Result<CommandOutput, InferenceError> {
11017 run_mlx_audio_command_with_env(runtime, subcommand, args, &[], detached_lease).await
11018}
11019
11020async fn run_admitted_blocking<F, T>(
11024 detached_lease: resource_policy::DetachedLocalLease,
11025 operation: F,
11026) -> Result<T, tokio::task::JoinError>
11027where
11028 F: FnOnce() -> T + Send + 'static,
11029 T: Send + 'static,
11030{
11031 tokio::task::spawn_blocking(move || {
11032 let _detached_lease = detached_lease;
11033 operation()
11034 })
11035 .await
11036}
11037
11038struct DetachedSpeechProcess {
11039 child: Option<tokio::process::Child>,
11040 lease: Option<resource_policy::DetachedLocalLease>,
11041}
11042
11043impl Drop for DetachedSpeechProcess {
11044 fn drop(&mut self) {
11045 let Some(mut child) = self.child.take() else {
11046 return;
11047 };
11048 let lease = self.lease.take();
11049 let _ = child.start_kill();
11050 if tokio::runtime::Handle::try_current().is_ok() {
11051 tokio::spawn(async move {
11052 let _ = child.wait().await;
11053 drop(lease);
11054 });
11055 } else {
11056 std::mem::forget((child, lease));
11059 }
11060 }
11061}
11062
11063async fn run_mlx_audio_command_with_env(
11064 runtime: &SpeechRuntime,
11065 subcommand: &str,
11066 args: &[String],
11067 envs: &[(String, String)],
11068 detached_lease: resource_policy::DetachedLocalLease,
11069) -> Result<CommandOutput, InferenceError> {
11070 let program = runtime.command_for(subcommand)?;
11071 let mut command = Command::new(program);
11072 command.args(args);
11073 for (key, value) in envs {
11074 command.env(key, value);
11075 }
11076 command
11077 .stdout(std::process::Stdio::piped())
11078 .stderr(std::process::Stdio::piped())
11079 .kill_on_drop(true);
11080 let child = command
11081 .spawn()
11082 .map_err(|err| InferenceError::InferenceFailed(format!("{}: {err}", program.display())))?;
11083 let mut owned = DetachedSpeechProcess {
11084 child: Some(child),
11085 lease: Some(detached_lease),
11086 };
11087 let mut stdout = owned
11088 .child
11089 .as_mut()
11090 .expect("speech child owned")
11091 .stdout
11092 .take()
11093 .ok_or_else(|| {
11094 InferenceError::InferenceFailed(format!("{} stdout unavailable", program.display()))
11095 })?;
11096 let mut stderr = owned
11097 .child
11098 .as_mut()
11099 .expect("speech child owned")
11100 .stderr
11101 .take()
11102 .ok_or_else(|| {
11103 InferenceError::InferenceFailed(format!("{} stderr unavailable", program.display()))
11104 })?;
11105 let stdout_reader = tokio::spawn(async move {
11106 let mut bytes = Vec::new();
11107 stdout.read_to_end(&mut bytes).await.map(|_| bytes)
11108 });
11109 let stderr_reader = tokio::spawn(async move {
11110 let mut bytes = Vec::new();
11111 stderr.read_to_end(&mut bytes).await.map(|_| bytes)
11112 });
11113 let status = owned
11114 .child
11115 .as_mut()
11116 .expect("speech child owned")
11117 .wait()
11118 .await
11119 .map_err(|err| InferenceError::InferenceFailed(format!("{}: {err}", program.display())))?;
11120 owned.child.take();
11121 owned.lease.take();
11122 let stdout = stdout_reader
11123 .await
11124 .map_err(|err| InferenceError::InferenceFailed(format!("speech stdout join: {err}")))?
11125 .map_err(|err| InferenceError::InferenceFailed(format!("speech stdout: {err}")))?;
11126 let stderr = stderr_reader
11127 .await
11128 .map_err(|err| InferenceError::InferenceFailed(format!("speech stderr join: {err}")))?
11129 .map_err(|err| InferenceError::InferenceFailed(format!("speech stderr: {err}")))?;
11130
11131 if status.success() {
11132 Ok(CommandOutput {
11133 stdout: String::from_utf8_lossy(&stdout).to_string(),
11134 stderr: String::from_utf8_lossy(&stderr).to_string(),
11135 })
11136 } else {
11137 Err(InferenceError::InferenceFailed(format!(
11138 "{} exited with {}: {}",
11139 program.display(),
11140 status,
11141 String::from_utf8_lossy(&stderr)
11142 )))
11143 }
11144}
11145
11146async fn bootstrap_speech_runtime(runtime: &SpeechRuntime) -> Result<(), InferenceError> {
11147 let python = select_speech_python()?;
11148
11149 let outcome = managed_venv::ensure_venv(&runtime.root, &python)
11155 .await
11156 .map_err(|err| InferenceError::InferenceFailed(err.to_string()))?;
11157 if outcome == managed_venv::VenvOutcome::Recreated {
11158 tracing::warn!(
11159 root = %runtime.root.display(),
11160 "managed speech runtime had an unusable interpreter; rebuilt it \
11161 (installed packages were discarded and are being reinstalled)"
11162 );
11163 }
11164
11165 run_command(
11166 "uv",
11167 &[
11168 "pip".to_string(),
11169 "install".to_string(),
11170 "--python".to_string(),
11171 runtime.python.display().to_string(),
11172 speech_runtime_mlx_audio_spec(),
11173 "misaki[en]".to_string(),
11174 speech_runtime_spacy_model_spec(),
11175 ],
11176 )
11177 .await?;
11178
11179 Ok(())
11180}
11181
11182async fn run_command(program: &str, args: &[String]) -> Result<(), InferenceError> {
11183 let output = Command::new(program)
11184 .args(args)
11185 .output()
11186 .await
11187 .map_err(|err| InferenceError::InferenceFailed(format!("{program}: {err}")))?;
11188
11189 if output.status.success() {
11190 Ok(())
11191 } else {
11192 Err(InferenceError::InferenceFailed(format!(
11193 "{} exited with {}: {}",
11194 program,
11195 output.status,
11196 String::from_utf8_lossy(&output.stderr)
11197 )))
11198 }
11199}
11200
11201fn select_speech_python() -> Result<String, InferenceError> {
11202 if let Ok(path) = std::env::var("CAR_SPEECH_PYTHON") {
11203 if !path.trim().is_empty() {
11204 return Ok(path);
11205 }
11206 }
11207
11208 for candidate in ["python3.13", "python3.12", "python3.11"] {
11209 if command_in_path(candidate) {
11210 return Ok(candidate.to_string());
11211 }
11212 }
11213
11214 Ok(SPEECH_RUNTIME_FALLBACK_PYTHON.to_string())
11224}
11225
11226const SPEECH_RUNTIME_FALLBACK_PYTHON: &str = "3.12";
11232
11233fn detect_speech_python() -> Option<String> {
11234 if let Ok(path) = std::env::var("CAR_SPEECH_PYTHON") {
11235 if !path.trim().is_empty() {
11236 return Some(path);
11237 }
11238 }
11239
11240 ["python3.13", "python3.12", "python3.11"]
11241 .into_iter()
11242 .find(|candidate| command_in_path(candidate))
11243 .map(str::to_string)
11244}
11245
11246fn speech_runtime_root_from_models_dir(_models_dir: &Path) -> PathBuf {
11247 if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
11248 if !path.trim().is_empty() {
11249 return PathBuf::from(path);
11250 }
11251 }
11252
11253 std::env::var_os("HOME")
11254 .or_else(|| std::env::var_os("USERPROFILE"))
11255 .map(PathBuf::from)
11256 .unwrap_or_else(|| PathBuf::from("."))
11257 .join(".car")
11258 .join("speech-runtime")
11259}
11260
11261fn command_in_path(name: &str) -> bool {
11262 std::env::var_os("PATH")
11263 .map(|paths| {
11264 std::env::split_paths(&paths).any(|dir| {
11265 let path = dir.join(name);
11266 path.exists() && path.is_file()
11267 })
11268 })
11269 .unwrap_or(false)
11270}
11271
11272fn speech_model_cached(schema: &ModelSchema) -> bool {
11273 match &schema.source {
11274 ModelSource::Mlx { hf_repo, .. } => huggingface_repo_has_snapshot(hf_repo),
11275 ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
11276 ModelSource::WindowsSpeech {} => cfg!(target_os = "windows"),
11278 ModelSource::Proprietary { .. } => false,
11281 _ => false,
11282 }
11283}
11284
11285fn model_source_configured(schema: &ModelSchema) -> bool {
11286 match &schema.source {
11287 ModelSource::RemoteApi {
11288 protocol: ApiProtocol::OpenRouter,
11289 ..
11290 } => crate::openrouter::credential_source().is_some(),
11291 ModelSource::RemoteApi {
11292 api_key_env,
11293 api_key_envs,
11294 ..
11295 } => {
11296 std::env::var(api_key_env).is_ok_and(|value| !value.trim().is_empty())
11297 || api_key_envs.iter().any(|env_var| {
11298 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11299 })
11300 }
11301 ModelSource::Proprietary { auth, .. } => match auth {
11302 ProprietaryAuth::ApiKeyEnv { env_var } => {
11303 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11304 }
11305 ProprietaryAuth::BearerTokenEnv { env_var } => {
11306 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11307 }
11308 ProprietaryAuth::OAuth2Pkce { .. } => matches!(
11309 car_auth::credential_authority_hint().state,
11310 car_auth::CredentialAuthorityState::Configured
11311 ),
11312 },
11313 ModelSource::VllmMlx { .. } => {
11314 std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available
11315 }
11316 ModelSource::Ollama { .. } => schema.available,
11317 ModelSource::CodexCli { .. } => crate::backend::codex_cli::is_available(),
11318 ModelSource::Mlx { .. }
11319 | ModelSource::ManagedVllmMlx { .. }
11320 | ModelSource::Local { .. }
11321 | ModelSource::WhisperCpp { .. } => true,
11322 ModelSource::WindowsSpeech {} => schema.available,
11324 ModelSource::AppleFoundationModels { .. } => schema.available,
11325 ModelSource::Delegated { .. } => true,
11330 }
11331}
11332
11333fn all_model_capabilities() -> [ModelCapability; 13] {
11334 [
11335 ModelCapability::Generate,
11336 ModelCapability::Embed,
11337 ModelCapability::Classify,
11338 ModelCapability::Code,
11339 ModelCapability::Reasoning,
11340 ModelCapability::Summarize,
11341 ModelCapability::ToolUse,
11342 ModelCapability::MultiToolCall,
11343 ModelCapability::Vision,
11344 ModelCapability::SpeechToText,
11345 ModelCapability::TextToSpeech,
11346 ModelCapability::ImageGeneration,
11347 ModelCapability::VideoGeneration,
11348 ]
11349}
11350
11351fn sort_capabilities(mut capabilities: Vec<ModelCapability>) -> Vec<ModelCapability> {
11352 capabilities.sort_by_key(|capability| {
11353 all_model_capabilities()
11354 .iter()
11355 .position(|candidate| candidate == capability)
11356 .unwrap_or(usize::MAX)
11357 });
11358 capabilities
11359}
11360
11361fn speech_model_source_label(schema: &ModelSchema) -> String {
11362 match &schema.source {
11363 ModelSource::Mlx { hf_repo, .. } => format!("mlx:{hf_repo}"),
11364 ModelSource::ManagedVllmMlx { hf_repo, .. } => {
11365 format!("managed-vllm-mlx:{hf_repo}")
11366 }
11367 ModelSource::WhisperCpp { model } => format!("whisper:{model}"),
11368 ModelSource::WindowsSpeech {} => "windows-speech".to_string(),
11369 ModelSource::Proprietary {
11370 provider, endpoint, ..
11371 } => format!("proprietary:{provider}:{endpoint}"),
11372 ModelSource::RemoteApi { endpoint, .. } => format!("remote:{endpoint}"),
11373 ModelSource::CodexCli { model } => format!("codex-cli:{model}"),
11374 ModelSource::Local { hf_repo, .. } => format!("local:{hf_repo}"),
11375 ModelSource::VllmMlx {
11376 endpoint,
11377 model_name,
11378 } => format!("vllm-mlx:{endpoint}:{model_name}"),
11379 ModelSource::Ollama { model_tag, host } => format!("ollama:{host}:{model_tag}"),
11380 ModelSource::AppleFoundationModels { use_case } => {
11381 format!(
11382 "apple-foundation:{}",
11383 use_case.as_deref().unwrap_or("default")
11384 )
11385 }
11386 ModelSource::Delegated { hint } => {
11387 format!("delegated:{}", hint.as_deref().unwrap_or("(none)"))
11388 }
11389 }
11390}
11391
11392fn rerank_prompt(instruction: &str, query: &str, document: &str) -> String {
11400 const SYSTEM: &str = "Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".";
11401 format!(
11402 "<|im_start|>system\n{SYSTEM}<|im_end|>\n\
11403 <|im_start|>user\n<Instruct>: {instruction}\n<Query>: {query}\n<Document>: {document}<|im_end|>\n\
11404 <|im_start|>assistant\n<think>\n\n</think>\n\n"
11405 )
11406}
11407
11408fn parse_api_returned_status(err: &str) -> Option<u16> {
11432 let lower = err.to_ascii_lowercase();
11433 let idx = lower.find("api returned ")?;
11434 let digits: String = lower[idx + "api returned ".len()..]
11435 .chars()
11436 .take_while(|c| c.is_ascii_digit())
11437 .take(3)
11438 .collect();
11439 digits.parse().ok()
11440}
11441
11442fn no_backend_recovery_hint(underlying: &str) -> Option<String> {
11443 let is_no_backend = underlying.contains("no credential")
11444 || underlying.contains("model not found")
11445 || underlying.contains("no models available")
11446 || underlying.contains("no inference runner");
11447 if !is_no_backend {
11448 return None;
11449 }
11450 Some(format!(
11451 "no inference backend is available. To install a local, tool-capable \
11452 model (no account required, works on Windows/Linux/macOS), run:\n \
11453 car models pull qwen/qwen3-4b:q4_k_m\n\
11454 To use Parslee's hosted models instead, run:\n \
11455 car auth login\n\
11456 (underlying error: {underlying})"
11457 ))
11458}
11459
11460pub fn is_auth_rejection_message(underlying: &str) -> bool {
11478 let l = underlying.to_ascii_lowercase();
11479 l.contains("org lookup failed")
11480 || l.contains("authentication required")
11481 || (l.contains("401") && l.contains("unauthorized"))
11482 || (l.contains("403") && l.contains("forbidden"))
11483 || l.contains("invalid_grant")
11484 || l.contains("token expired")
11485}
11486
11487pub fn is_auth_failure_message(message: &str) -> bool {
11494 let lower = message.to_ascii_lowercase();
11495 AUTH_FAILURE_MESSAGE_MARKERS
11496 .iter()
11497 .any(|marker| lower.contains(marker))
11498 || is_auth_rejection_message(message)
11499}
11500
11501fn record_auth_dead_lane(slot: &mut Option<String>, candidate: &str, error: &str) {
11510 if slot.is_none() && is_auth_rejection_message(error) {
11511 *slot = Some(candidate.to_string());
11512 }
11513}
11514
11515fn is_local_last_resort(appended_id: Option<&str>, candidate_id: &str) -> bool {
11520 appended_id == Some(candidate_id)
11521}
11522
11523fn report_local_last_resort_served(
11527 appended_id: Option<&str>,
11528 candidate_id: &str,
11529 resolved_id: &str,
11530) -> bool {
11531 let served = is_local_last_resort(appended_id, candidate_id);
11532 if served {
11533 tracing::warn!(
11534 local_model = %resolved_id,
11535 "on-device last-resort model served inference turn"
11536 );
11537 }
11538 served
11539}
11540
11541fn auth_expired_recovery_hint(underlying: &str) -> Option<String> {
11551 if !is_auth_rejection_message(underlying) {
11552 return None;
11553 }
11554 Some(format!(
11555 "your Parslee session has expired or was rejected — re-authenticate:\n \
11556 car auth login\n\
11557 Or install a tool-capable on-device model so CAR can answer (including \
11558 tool use) without an account:\n \
11559 car models pull qwen/qwen3-4b:q4_k_m\n\
11560 (underlying error: {underlying})"
11561 ))
11562}
11563
11564fn score_from_rerank_output(text: &str, model_name: &str) -> f32 {
11565 let normalized: String = text
11570 .to_ascii_lowercase()
11571 .chars()
11572 .map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' })
11573 .collect();
11574 for tok in normalized.split_ascii_whitespace().take(5) {
11575 match tok {
11576 "yes" => return 1.0,
11577 "no" => return 0.0,
11578 _ => continue,
11579 }
11580 }
11581 tracing::warn!(
11582 model = %model_name,
11583 output = %text,
11584 "rerank: first tokens contain neither `yes` nor `no`; returning neutral 0.5"
11585 );
11586 0.5
11587}
11588
11589fn default_speech_voice(schema: &ModelSchema) -> Option<String> {
11590 if schema.provider == "elevenlabs" {
11591 Some("JBFqnCBsd6RMkjVDRZzb".to_string())
11592 } else if schema.name == "Kokoro-82M-6bit" || schema.name == "Kokoro-82M-bf16" {
11593 Some("af_heart".to_string())
11594 } else if schema.name == "Qwen3-TTS-12Hz-1.7B-Base-5bit" {
11595 Some("Chelsie".to_string())
11596 } else {
11597 None
11598 }
11599}
11600
11601#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
11603 find_latest_huggingface_snapshot(repo_id).is_some()
11604}
11605
11606fn huggingface_repo_dir(repo_id: &str) -> PathBuf {
11607 let cache_root = std::env::var("HF_HOME")
11608 .map(PathBuf::from)
11609 .unwrap_or_else(|_| {
11610 std::env::var_os("HOME")
11611 .or_else(|| std::env::var_os("USERPROFILE"))
11612 .map(PathBuf::from)
11613 .unwrap_or_else(|| PathBuf::from("."))
11614 .join(".cache")
11615 .join("huggingface")
11616 })
11617 .join("hub");
11618 cache_root.join(format!("models--{}", repo_id.replace('/', "--")))
11619}
11620
11621fn find_latest_huggingface_snapshot(repo_id: &str) -> Option<PathBuf> {
11622 let snapshots = huggingface_repo_dir(repo_id).join("snapshots");
11623 std::fs::read_dir(snapshots)
11624 .ok()?
11625 .filter_map(Result::ok)
11626 .map(|entry| entry.path())
11627 .find(|path| path.is_dir() && snapshot_looks_ready(path))
11628}
11629
11630fn snapshot_looks_ready(path: &Path) -> bool {
11631 if path.join("config.json").exists() || path.join("model_index.json").exists() {
11632 return true;
11633 }
11634 snapshot_contains_ext(path, "safetensors")
11635}
11636
11637fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
11638 let Ok(entries) = std::fs::read_dir(root) else {
11639 return false;
11640 };
11641 entries.filter_map(Result::ok).any(|entry| {
11642 let path = entry.path();
11643 if path.is_dir() {
11644 snapshot_contains_ext(&path, ext)
11645 } else {
11646 let ext_matches = path
11647 .extension()
11648 .and_then(|value| value.to_str())
11649 .map(|value| value.eq_ignore_ascii_case(ext))
11650 .unwrap_or(false);
11651 ext_matches && crate::download::cache_file_usable(&path)
11655 }
11656 })
11657}
11658
11659#[allow(dead_code)] fn count_files_recursive(root: &Path) -> usize {
11661 let Ok(entries) = std::fs::read_dir(root) else {
11662 return 0;
11663 };
11664 entries
11665 .filter_map(Result::ok)
11666 .map(|entry| entry.path())
11667 .map(|path| {
11668 if path.is_dir() {
11669 count_files_recursive(&path)
11670 } else if path.is_file() {
11671 1
11672 } else {
11673 0
11674 }
11675 })
11676 .sum()
11677}
11678
11679async fn download_hf_repo_snapshot(repo_id: &str) -> Result<(PathBuf, usize), InferenceError> {
11680 let api = hf_hub::api::tokio::ApiBuilder::from_env()
11681 .with_progress(false)
11682 .build()
11683 .map_err(|e| InferenceError::DownloadFailed(format!("init hf api: {e}")))?;
11684 let repo = api.model(repo_id.to_string());
11685 let info = repo
11686 .info()
11687 .await
11688 .map_err(|e| InferenceError::DownloadFailed(format!("{repo_id}: {e}")))?;
11689
11690 let snapshot_path = huggingface_repo_dir(repo_id)
11691 .join("snapshots")
11692 .join(&info.sha);
11693 let mut downloaded = 0usize;
11694 for sibling in &info.siblings {
11695 let local_path = snapshot_path.join(&sibling.rfilename);
11696 if crate::download::cache_file_usable(&local_path) {
11704 downloaded += 1;
11705 continue;
11706 }
11707 let _ = std::fs::remove_file(&local_path);
11712 repo.download(&sibling.rfilename).await.map_err(|e| {
11713 InferenceError::DownloadFailed(format!("{repo_id}/{}: {e}", sibling.rfilename))
11714 })?;
11715 downloaded += 1;
11716 }
11717
11718 Ok((snapshot_path, downloaded))
11719}
11720
11721fn temp_work_dir(prefix: &str) -> Result<PathBuf, InferenceError> {
11722 let unique = SystemTime::now()
11723 .duration_since(UNIX_EPOCH)
11724 .map_err(|e| InferenceError::InferenceFailed(format!("clock error: {e}")))?
11725 .as_nanos();
11726 let dir = std::env::temp_dir().join(format!("car-inference-{prefix}-{unique}"));
11727 std::fs::create_dir_all(&dir)?;
11728 Ok(dir)
11729}
11730
11731fn ensure_parent_dir(path: &Path) -> Result<(), InferenceError> {
11732 if let Some(parent) = path.parent() {
11733 std::fs::create_dir_all(parent)?;
11734 }
11735 Ok(())
11736}
11737
11738fn requested_or_temp_output(
11739 output_path: Option<&str>,
11740 format: &str,
11741) -> Result<PathBuf, InferenceError> {
11742 if let Some(path) = output_path {
11743 return Ok(PathBuf::from(path));
11744 }
11745 let dir = temp_work_dir("audio-out")?;
11746 Ok(dir.join(format!("speech.{format}")))
11747}
11748
11749#[allow(dead_code)] fn requested_or_temp_media_output(
11751 output_path: Option<&str>,
11752 format: &str,
11753 stem: &str,
11754) -> Result<PathBuf, InferenceError> {
11755 if let Some(path) = output_path {
11756 return Ok(PathBuf::from(path));
11757 }
11758 let dir = temp_work_dir(&format!("{stem}-out"))?;
11759 Ok(dir.join(format!("{stem}.{format}")))
11760}
11761
11762fn materialize_audio_output(
11763 produced: &Path,
11764 requested: Option<&str>,
11765 format: &str,
11766) -> Result<PathBuf, InferenceError> {
11767 if let Some(path) = requested {
11768 let dest = PathBuf::from(path);
11769 ensure_parent_dir(&dest)?;
11770 std::fs::copy(produced, &dest)?;
11771 Ok(dest)
11772 } else {
11773 let dest = requested_or_temp_output(None, format)?;
11774 ensure_parent_dir(&dest)?;
11775 std::fs::copy(produced, &dest)?;
11776 Ok(dest)
11777 }
11778}
11779
11780#[cfg(target_os = "windows")]
11786fn winrt_synthesize_wav(text: &str, voice: &str, rate: f64) -> Result<Vec<u8>, InferenceError> {
11787 use windows::core::HSTRING;
11788 use windows::Media::SpeechSynthesis::SpeechSynthesizer;
11789 use windows::Storage::Streams::DataReader;
11790
11791 let err = |m: String| InferenceError::InferenceFailed(m);
11792 let synth =
11793 SpeechSynthesizer::new().map_err(|e| err(format!("SpeechSynthesizer::new: {e}")))?;
11794 if let Ok(opts) = synth.Options() {
11795 let _ = opts.SetSpeakingRate(rate.clamp(0.5, 6.0));
11796 }
11797 if !voice.is_empty() {
11798 if let Ok(all) = SpeechSynthesizer::AllVoices() {
11799 let want = voice.to_lowercase();
11800 let count = all.Size().unwrap_or(0);
11801 for i in 0..count {
11802 if let Ok(info) = all.GetAt(i) {
11803 if let Ok(name) = info.DisplayName() {
11804 if name.to_string_lossy().to_lowercase().contains(&want) {
11805 let _ = synth.SetVoice(&info);
11806 break;
11807 }
11808 }
11809 }
11810 }
11811 }
11812 }
11813 let stream = synth
11814 .SynthesizeTextToStreamAsync(&HSTRING::from(text))
11815 .map_err(|e| err(format!("SynthesizeTextToStreamAsync: {e}")))?
11816 .get()
11817 .map_err(|e| err(format!("synthesize await: {e}")))?;
11818 let size = stream
11819 .Size()
11820 .map_err(|e| err(format!("stream size: {e}")))?;
11821 let input = stream
11822 .GetInputStreamAt(0)
11823 .map_err(|e| err(format!("input stream: {e}")))?;
11824 let reader =
11825 DataReader::CreateDataReader(&input).map_err(|e| err(format!("data reader: {e}")))?;
11826 reader
11827 .LoadAsync(size as u32)
11828 .map_err(|e| err(format!("load async: {e}")))?
11829 .get()
11830 .map_err(|e| err(format!("load await: {e}")))?;
11831 let mut buf = vec![0u8; size as usize];
11832 reader
11833 .ReadBytes(&mut buf)
11834 .map_err(|e| err(format!("read bytes: {e}")))?;
11835 Ok(buf)
11836}
11837
11838#[allow(dead_code)] fn read_transcription_result(output_prefix: &Path) -> Result<Option<String>, InferenceError> {
11840 let candidates = [
11841 output_prefix.with_extension("json"),
11842 output_prefix.to_path_buf(),
11843 ];
11844
11845 for path in candidates {
11846 if path.exists() {
11847 let contents = std::fs::read_to_string(path)?;
11848 if let Some(text) = extract_text_from_payload(&contents) {
11849 return Ok(Some(text));
11850 }
11851 }
11852 }
11853
11854 Ok(None)
11855}
11856
11857#[allow(dead_code)] fn extract_text_from_payload(payload: &str) -> Option<String> {
11859 let value: serde_json::Value = serde_json::from_str(payload).ok()?;
11860 if let Some(text) = value.get("text").and_then(|v| v.as_str()) {
11861 return Some(text.to_string());
11862 }
11863 if let Some(transcripts) = value.get("transcripts").and_then(|v| v.as_array()) {
11864 let joined = transcripts
11865 .iter()
11866 .filter_map(|item| item.get("text").and_then(|v| v.as_str()))
11867 .collect::<Vec<_>>()
11868 .join("\n");
11869 if !joined.is_empty() {
11870 return Some(joined);
11871 }
11872 }
11873 if let Some(items) = value.as_array() {
11874 let joined = items
11875 .iter()
11876 .filter_map(|item| {
11877 item.get("text")
11878 .or_else(|| item.get("Content"))
11879 .and_then(|v| v.as_str())
11880 })
11881 .collect::<Vec<_>>()
11882 .join(" ");
11883 if !joined.is_empty() {
11884 return Some(joined);
11885 }
11886 }
11887 None
11888}
11889
11890#[allow(dead_code)] fn find_audio_file(output_dir: &Path) -> Result<Option<PathBuf>, InferenceError> {
11892 let mut audio_files = Vec::new();
11893 collect_audio_files(output_dir, &mut audio_files)?;
11894 audio_files.sort();
11895 Ok(audio_files.into_iter().next())
11896}
11897
11898#[allow(dead_code)] fn collect_audio_files(dir: &Path, audio_files: &mut Vec<PathBuf>) -> Result<(), InferenceError> {
11900 for entry in std::fs::read_dir(dir)? {
11901 let path = entry?.path();
11902 if path.is_dir() {
11903 collect_audio_files(&path, audio_files)?;
11904 } else if matches!(
11905 path.extension().and_then(|ext| ext.to_str()),
11906 Some("wav" | "mp3" | "flac" | "pcm" | "m4a")
11907 ) {
11908 audio_files.push(path);
11909 }
11910 }
11911 Ok(())
11912}
11913
11914fn media_type_for_format(format: &str) -> String {
11915 match format.to_ascii_lowercase().as_str() {
11916 "mp3" => "audio/mpeg".to_string(),
11917 "flac" => "audio/flac".to_string(),
11918 "pcm" => "audio/L16".to_string(),
11919 "m4a" => "audio/mp4".to_string(),
11920 _ => "audio/wav".to_string(),
11921 }
11922}
11923
11924fn kokoro_lang_code(language: Option<&str>) -> &'static str {
11925 match language.unwrap_or("en").to_ascii_lowercase().as_str() {
11926 "en-gb" | "british" | "british english" => "b",
11927 "ja" | "japanese" => "j",
11928 "zh" | "zh-cn" | "mandarin" | "chinese" => "z",
11929 "es" | "spanish" => "e",
11930 "fr" | "french" => "f",
11931 _ => "a",
11932 }
11933}
11934
11935#[allow(dead_code)] fn normalize_lang_code(language: &str) -> String {
11937 match language.to_ascii_lowercase().as_str() {
11938 "english" | "en-us" | "en_us" => "en".to_string(),
11939 "spanish" => "es".to_string(),
11940 "french" => "fr".to_string(),
11941 "japanese" => "ja".to_string(),
11942 "chinese" | "mandarin" => "zh".to_string(),
11943 other => match other {
11944 "en" | "es" | "fr" | "ja" | "zh" => other.to_string(),
11945 _ => "en".to_string(),
11946 },
11947 }
11948}
11949
11950fn elevenlabs_auth(
11951 schema: &ModelSchema,
11952 resolve_credential: fn(&str) -> Option<String>,
11953) -> Result<(String, String), InferenceError> {
11954 match &schema.source {
11955 ModelSource::Proprietary {
11956 endpoint,
11957 auth: schema::ProprietaryAuth::ApiKeyEnv { env_var },
11958 ..
11959 } => {
11960 let key = resolve_credential(env_var).ok_or_else(|| {
11961 InferenceError::InferenceFailed(format!(
11962 "missing API key {env_var}; set the environment variable or \
11963 store it with `car secrets put {env_var}`"
11964 ))
11965 })?;
11966 Ok((endpoint.clone(), key))
11967 }
11968 _ => Err(InferenceError::InferenceFailed(format!(
11969 "model {} is not an ElevenLabs proprietary model",
11970 schema.id
11971 ))),
11972 }
11973}
11974
11975fn elevenlabs_output_format(format: &str) -> &'static str {
11976 match format.to_ascii_lowercase().as_str() {
11977 "mp3" => "mp3_44100_128",
11978 "pcm" => "pcm_16000",
11979 _ => "wav_44100",
11980 }
11981}
11982
11983fn benchmark_priors_paths(state_models_dir: &Path) -> Vec<PathBuf> {
11988 let mut paths = Vec::new();
11989
11990 let direct = state_models_dir.join("benchmark_priors.json");
11991 if !paths.contains(&direct) {
11992 paths.push(direct);
11993 }
11994
11995 if let Some(parent) = state_models_dir.parent() {
11996 let parent_path = parent.join("benchmark_priors.json");
11997 if !paths.contains(&parent_path) {
11998 paths.push(parent_path);
11999 }
12000 }
12001
12002 if let Some(path) = std::env::var_os("CAR_BENCHMARK_PRIORS_PATH") {
12003 let path = PathBuf::from(path);
12004 if !paths.contains(&path) {
12005 paths.push(path);
12006 }
12007 }
12008
12009 paths
12010}
12011
12012fn load_benchmark_prior_health(
12013 state_models_dir: &Path,
12014 schemas: &[ModelSchema],
12015) -> Vec<ModelBenchmarkPriorHealth> {
12016 let mut priors = std::collections::BTreeMap::new();
12017 for path in benchmark_priors_paths(state_models_dir) {
12018 let Ok(loaded) = routing_ext::load_benchmark_priors(&path) else {
12019 continue;
12020 };
12021 for (model_id, prior) in loaded {
12022 let model_name = schemas
12023 .iter()
12024 .find(|schema| schema.id == model_id)
12025 .map(|schema| schema.name.clone());
12026 priors.insert(
12027 model_id.clone(),
12028 ModelBenchmarkPriorHealth {
12029 model_id,
12030 model_name,
12031 overall_score: prior.overall_score,
12032 overall_latency_ms: prior.overall_latency_ms,
12033 task_scores: prior.task_scores,
12034 task_latency_ms: prior.task_latency_ms,
12035 source_path: path.clone(),
12036 },
12037 );
12038 }
12039 }
12040
12041 priors.into_values().collect()
12042}
12043
12044fn kokoro_runtime_fallback_enabled() -> bool {
12045 std::env::var("CAR_SPEECH_KOKORO_FALLBACK")
12046 .ok()
12047 .map(|value| {
12048 !matches!(
12049 value.trim().to_ascii_lowercase().as_str(),
12050 "0" | "false" | "off"
12051 )
12052 })
12053 .unwrap_or(true)
12054}
12055
12056fn speech_runtime_mlx_audio_spec() -> String {
12057 std::env::var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC")
12058 .ok()
12059 .filter(|value| !value.trim().is_empty())
12060 .unwrap_or_else(|| "mlx-audio==0.4.2".to_string())
12061}
12062
12063fn speech_runtime_spacy_model_spec() -> String {
12064 std::env::var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC")
12065 .ok()
12066 .filter(|value| !value.trim().is_empty())
12067 .unwrap_or_else(|| {
12068 "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl".to_string()
12069 })
12070}
12071
12072#[cfg(test)]
12073pub(crate) fn run_in_isolated_test_process(test_name: &str, sentinel: &str) -> bool {
12074 static CHILD_PROCESS_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
12075
12076 if std::env::var_os(sentinel).is_some() {
12077 return true;
12078 }
12079
12080 let _child_process = CHILD_PROCESS_MUTEX
12081 .lock()
12082 .unwrap_or_else(std::sync::PoisonError::into_inner);
12083 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
12084 command
12085 .arg("--exact")
12086 .arg(test_name)
12087 .arg("--nocapture")
12088 .arg("--test-threads=1")
12089 .env(sentinel, "1");
12090 for name in [
12091 car_home::ENV_VAR,
12092 "CAR_SECRETS_FILE_DIR",
12093 "CAR_AUTH_LOCK_PATH",
12094 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
12095 car_auth::PARSLEE_API_BASE_KEY,
12096 crate::openrouter::API_KEY_ENV,
12097 "OPENAI_API_KEY",
12098 "ANTHROPIC_API_KEY",
12099 "GOOGLE_API_KEY",
12100 "ELEVENLABS_API_KEY",
12101 "SSL_CERT_FILE",
12102 "SSL_CERT_DIR",
12103 ] {
12104 command.env_remove(name);
12105 }
12106 let output = command.output().expect("spawn isolated inference test");
12107 assert!(
12108 output.status.success(),
12109 "isolated {test_name} failed\nstdout:\n{}\nstderr:\n{}",
12110 String::from_utf8_lossy(&output.stdout),
12111 String::from_utf8_lossy(&output.stderr),
12112 );
12113 false
12114}
12115
12116#[cfg(test)]
12117mod tests {
12118 use super::*;
12119 use std::ffi::OsString;
12120 use tempfile::TempDir;
12121
12122 #[test]
12129 fn local_models_keep_the_callers_default_output_budget() {
12130 use crate::tasks::generate::DEFAULT_MAX_TOKENS;
12131
12132 let catalog = crate::registry::builtin_catalog();
12133 let local = catalog
12134 .iter()
12135 .find(|s| s.id == "mlx/qwen3-8b:4bit")
12136 .expect("mlx/qwen3-8b:4bit is a builtin catalog entry");
12137
12138 assert!(local.is_local(), "mlx/* is decoded in-process");
12139 assert_eq!(
12140 local.effective_max_output(),
12141 32_768,
12142 "the budget that made the reported turn ~24 minutes long"
12143 );
12144 assert_eq!(
12145 resolved_max_tokens(DEFAULT_MAX_TOKENS, local),
12146 DEFAULT_MAX_TOKENS,
12147 "a local model must keep the budget the caller actually asked for"
12148 );
12149
12150 let mut remote = catalog
12153 .iter()
12154 .find(|s| !s.is_local())
12155 .expect("the builtin catalog ships remote models")
12156 .clone();
12157 remote.context_length = local.context_length;
12158 remote.max_output_tokens = local.max_output_tokens;
12159 assert!(!remote.is_local());
12160 assert_eq!(
12161 resolved_max_tokens(DEFAULT_MAX_TOKENS, &remote),
12162 32_768,
12163 "remote models still get their advertised output budget"
12164 );
12165
12166 assert_eq!(resolved_max_tokens(512, local), 512);
12168 assert_eq!(resolved_max_tokens(512, &remote), 512);
12169
12170 let codex = catalog
12171 .iter()
12172 .find(|s| s.id == "openai/gpt-5.6-sol:high")
12173 .expect("subscription-backed Codex row is builtin");
12174 assert_eq!(
12175 resolved_max_tokens(DEFAULT_MAX_TOKENS, codex),
12176 DEFAULT_MAX_TOKENS,
12177 "an approximate instruction must not widen the default to 128K tokens"
12178 );
12179
12180 if let Some(vllm) = catalog.iter().find(|s| s.is_vllm_mlx()) {
12186 assert!(vllm.is_local(), "vLLM-MLX runs on this machine");
12187 assert!(
12188 !vllm.decodes_in_process(),
12189 "but CAR does not decode it token by token"
12190 );
12191 assert_eq!(
12192 resolved_max_tokens(DEFAULT_MAX_TOKENS, vllm),
12193 vllm.effective_max_output(),
12194 );
12195 }
12196 }
12197
12198 #[test]
12201 fn a_ceiling_stop_fails_instead_of_retrying() {
12202 use EmptyPassAction::*;
12203 const CEILING: Option<&str> = Some(LOCAL_DECODE_TIMEOUT_STOP_REASON);
12204
12205 assert_eq!(
12208 classify_empty_pass(true, CEILING, "", true),
12209 FailDecodeCeiling
12210 );
12211 assert_eq!(
12212 classify_empty_pass(false, CEILING, "", true),
12213 FailDecodeCeiling
12214 );
12215 assert_eq!(
12217 classify_empty_pass(true, CEILING, " \n ", true),
12218 FailDecodeCeiling
12219 );
12220
12221 assert_eq!(
12224 classify_empty_pass(true, Some("length"), "", true),
12225 RetryWithoutThinking
12226 );
12227 assert_eq!(
12228 classify_empty_pass(true, None, "", true),
12229 RetryWithoutThinking
12230 );
12231
12232 assert_eq!(classify_empty_pass(false, Some("stop"), "", true), Accept);
12235
12236 assert_eq!(classify_empty_pass(true, CEILING, "partial", true), Accept);
12239 assert_eq!(classify_empty_pass(true, CEILING, "", false), Accept);
12240
12241 assert_ne!(LOCAL_DECODE_TIMEOUT_STOP_REASON, "timeout");
12245 assert_eq!(
12246 classify_empty_pass(true, Some("timeout"), "", true),
12247 RetryWithoutThinking
12248 );
12249 }
12250
12251 #[test]
12255 fn decode_deadline_and_heartbeat_predicates() {
12256 use std::time::Duration;
12257 let limit = Duration::from_secs(300);
12258
12259 assert!(!deadline_exceeded(Duration::from_secs(86_400), None));
12261
12262 assert!(!deadline_exceeded(Duration::from_secs(299), Some(limit)));
12263 assert!(deadline_exceeded(limit, Some(limit)));
12266 assert!(deadline_exceeded(Duration::from_secs(301), Some(limit)));
12267
12268 let every = Duration::from_secs(10);
12269 assert!(!heartbeat_due(
12271 Duration::from_secs(9),
12272 Duration::ZERO,
12273 every
12274 ));
12275 assert!(heartbeat_due(
12276 Duration::from_secs(10),
12277 Duration::ZERO,
12278 every
12279 ));
12280 assert!(!heartbeat_due(
12282 Duration::from_secs(11),
12283 Duration::from_secs(10),
12284 every
12285 ));
12286 assert!(heartbeat_due(
12287 Duration::from_secs(20),
12288 Duration::from_secs(10),
12289 every
12290 ));
12291 assert!(!heartbeat_due(
12293 Duration::from_secs(5),
12294 Duration::from_secs(10),
12295 every
12296 ));
12297 }
12298
12299 #[test]
12300 fn decode_timeout_parses_with_a_safe_fallback() {
12301 let default = std::time::Duration::from_secs(DEFAULT_LOCAL_DECODE_TIMEOUT_SECS);
12302 assert_eq!(parse_decode_timeout(None), Some(default));
12303 assert_eq!(
12304 parse_decode_timeout(Some(" 45 ")),
12305 Some(std::time::Duration::from_secs(45))
12306 );
12307 assert_eq!(
12308 parse_decode_timeout(Some("0")),
12309 None,
12310 "0 disables the ceiling"
12311 );
12312 assert_eq!(
12313 parse_decode_timeout(Some("banana")),
12314 Some(default),
12315 "garbage must fall back to the default, never silently disable it"
12316 );
12317 }
12318
12319 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12324 #[test]
12325 fn local_decode_stops_at_the_wall_clock_ceiling() {
12326 struct NeverStops;
12329
12330 impl crate::backend::local::TextDecoder for NeverStops {
12331 fn encode(&self, _text: &str) -> Result<Vec<u32>, InferenceError> {
12332 Ok(vec![1, 2, 3])
12333 }
12334 fn decode(&self, tokens: &[u32]) -> Result<String, InferenceError> {
12335 Ok("x".repeat(tokens.len()))
12336 }
12337 fn forward(
12338 &mut self,
12339 _tokens: &[u32],
12340 _pos: usize,
12341 ) -> Result<Vec<f32>, InferenceError> {
12342 std::thread::sleep(std::time::Duration::from_millis(5));
12343 let mut logits = vec![0.0f32; 16];
12344 logits[7] = 10.0;
12345 Ok(logits)
12346 }
12347 fn eos_ids(&self) -> Vec<u32> {
12348 vec![0]
12349 }
12350 fn context_length(&self) -> usize {
12351 4096
12352 }
12353 fn clear_kv_cache(&mut self) {}
12354 }
12355
12356 let params = GenerateParams {
12359 max_tokens: 100_000,
12360 temperature: 0.0,
12361 ..Default::default()
12362 };
12363 let ceiling = std::time::Duration::from_millis(300);
12364
12365 let mut backend = NeverStops;
12366 let started = std::time::Instant::now();
12367 let generated = match InferenceEngine::drive_generation_with_timeout(
12368 &mut backend,
12369 "anything",
12370 ¶ms,
12371 Some(ceiling),
12372 ) {
12373 Ok(generated) => generated,
12374 Err(e) => panic!(
12376 "a decode cut short by the ceiling returns its partial text, not an error: {}",
12377 e.into_inner()
12378 ),
12379 };
12380 let elapsed = started.elapsed();
12381
12382 assert_eq!(
12383 generated.stop_reason.as_deref(),
12384 Some(LOCAL_DECODE_TIMEOUT_STOP_REASON),
12385 "the caller must be able to tell a deadline stop from a clean finish"
12386 );
12387 assert!(
12388 InferenceResult {
12389 text: generated.text.clone(),
12390 bounding_boxes: Vec::new(),
12391 tool_calls: vec![],
12392 trace_id: String::new(),
12393 model_used: String::new(),
12394 model_identity: Default::default(),
12395 latency_ms: 0,
12396 time_to_first_token_ms: None,
12397 usage: None,
12398 provider_output_items: Vec::new(),
12399 thinking: Vec::new(),
12400 stop_reason: generated.stop_reason.clone(),
12401 auth_fallback_from: None,
12402 local_last_resort: false,
12403 fallback_from: Vec::new(),
12404 }
12405 .was_truncated(),
12406 "a ceiling stop is a cut-short answer, not a complete one"
12407 );
12408 assert!(
12409 generated.completion_tokens > 0,
12410 "the partial response is kept, not discarded"
12411 );
12412 assert!(
12413 generated.completion_tokens < params.max_tokens,
12414 "the loop stopped on the clock, not by exhausting the budget"
12415 );
12416 assert!(
12417 elapsed < std::time::Duration::from_secs(30),
12418 "bounded in wall clock; took {elapsed:?}"
12419 );
12420 }
12421
12422 struct RestoredEnvironment(Vec<(&'static str, Option<OsString>)>);
12423
12424 impl RestoredEnvironment {
12425 fn capture(names: &[&'static str]) -> Self {
12426 Self(
12427 names
12428 .iter()
12429 .map(|name| (*name, std::env::var_os(name)))
12430 .collect(),
12431 )
12432 }
12433 }
12434
12435 impl Drop for RestoredEnvironment {
12436 fn drop(&mut self) {
12437 for (name, value) in &self.0 {
12438 unsafe {
12439 match value {
12440 Some(value) => std::env::set_var(name, value),
12441 None => std::env::remove_var(name),
12442 }
12443 }
12444 }
12445 }
12446 }
12447
12448 struct FixtureLocalOffload {
12449 emit_done: bool,
12450 }
12451
12452 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12457 struct ExactPinCaptureOffload {
12458 dispatched_models: std::sync::Mutex<Vec<String>>,
12459 }
12460
12461 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12462 impl ExactPinCaptureOffload {
12463 fn new() -> Self {
12464 Self {
12465 dispatched_models: std::sync::Mutex::new(Vec::new()),
12466 }
12467 }
12468
12469 fn dispatched_models(&self) -> Vec<String> {
12470 self.dispatched_models.lock().unwrap().clone()
12471 }
12472 }
12473
12474 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12475 #[async_trait::async_trait]
12476 impl crate::offload::LocalGenerationOffload for ExactPinCaptureOffload {
12477 async fn generate(
12478 &self,
12479 request: GenerateRequest,
12480 ) -> Result<InferenceResult, InferenceError> {
12481 let model_id = request.model.expect("resolved worker model");
12482 self.dispatched_models
12483 .lock()
12484 .unwrap()
12485 .push(model_id.clone());
12486 Ok(InferenceResult {
12487 text: "exact pin".into(),
12488 tool_calls: vec![],
12489 bounding_boxes: vec![],
12490 trace_id: "worker-trace".into(),
12491 model_used: model_id,
12492 model_identity: Default::default(),
12493 latency_ms: 0,
12494 time_to_first_token_ms: None,
12495 usage: None,
12496 provider_output_items: vec![],
12497 thinking: vec![],
12498 stop_reason: Some("stop".into()),
12499 auth_fallback_from: None,
12500 local_last_resort: false,
12501 fallback_from: Vec::new(),
12502 })
12503 }
12504
12505 async fn stream(
12506 &self,
12507 request: GenerateRequest,
12508 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
12509 let model_id = request.model.expect("resolved worker model");
12510 self.dispatched_models.lock().unwrap().push(model_id);
12511 let (tx, rx) = tokio::sync::mpsc::channel(2);
12512 tx.send(StreamEvent::Done {
12513 text: "exact pin".into(),
12514 tool_calls: vec![],
12515 })
12516 .await
12517 .unwrap();
12518 Ok(rx)
12519 }
12520 }
12521
12522 struct ReconcileOffload {
12523 calls: std::sync::Mutex<Vec<String>>,
12524 release_acknowledged: bool,
12525 }
12526
12527 #[async_trait::async_trait]
12528 impl crate::offload::LocalGenerationOffload for ReconcileOffload {
12529 async fn generate(
12530 &self,
12531 _request: GenerateRequest,
12532 ) -> Result<InferenceResult, InferenceError> {
12533 unreachable!("residency reconciliation fixture")
12534 }
12535
12536 async fn stream(
12537 &self,
12538 _request: GenerateRequest,
12539 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
12540 unreachable!("residency reconciliation fixture")
12541 }
12542
12543 fn resident_allocation_id(&self, model_id: &str) -> Option<String> {
12544 self.calls
12545 .lock()
12546 .unwrap()
12547 .push(format!("allocation:{model_id}"));
12548 Some(format!("worker:{model_id}:generation-7"))
12549 }
12550
12551 async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
12552 self.calls
12553 .lock()
12554 .unwrap()
12555 .push(format!("release:{model_id}"));
12556 Ok(self.release_acknowledged)
12557 }
12558 }
12559
12560 fn reconciliation_reservation() -> (
12561 Arc<crate::resource_policy::LocalAdmissionCoordinator>,
12562 crate::resource_policy::LocalLoadReservation,
12563 ) {
12564 struct FixedProbe;
12565 impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
12566 fn available_memory_mb(
12567 &self,
12568 ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
12569 Ok(Some(24_000))
12570 }
12571 }
12572 let coordinator = Arc::new(
12573 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
12574 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
12575 crate::hardware::HardwareInfo {
12576 total_ram_mb: 32 * 1024,
12577 ..crate::hardware::HardwareInfo::detect()
12578 },
12579 Arc::new(FixedProbe),
12580 ),
12581 );
12582 let reservation = coordinator
12583 .reserve_measured_host_allocation(
12584 "expected/model",
12585 "expected/model#request",
12586 1024 * 1024,
12587 0,
12588 )
12589 .unwrap();
12590 (coordinator, reservation)
12591 }
12592
12593 #[tokio::test]
12594 async fn worker_residency_reconciliation_releases_mismatched_ack_before_returning_error() {
12595 let offload = ReconcileOffload {
12596 calls: std::sync::Mutex::new(Vec::new()),
12597 release_acknowledged: true,
12598 };
12599 let (_coordinator, mut reservation) = reconciliation_reservation();
12600 let error = InferenceEngine::reconcile_worker_residency(
12601 &offload,
12602 "expected/model",
12603 &crate::offload::LocalWorkerResidency {
12604 model_id: "wrong/model".into(),
12605 measured_weights_bytes: 2 * 1024 * 1024,
12606 },
12607 backend_cache::BackendRetention::Resident,
12608 &mut reservation,
12609 )
12610 .await
12611 .unwrap_err();
12612 assert!(error.to_string().contains("wrong/model"));
12613 assert_eq!(
12614 *offload.calls.lock().unwrap(),
12615 vec!["release:wrong/model".to_string()]
12616 );
12617 }
12618
12619 #[tokio::test]
12620 async fn worker_residency_reconciliation_publishes_exact_owner_for_matching_ack() {
12621 let offload = ReconcileOffload {
12622 calls: std::sync::Mutex::new(Vec::new()),
12623 release_acknowledged: false,
12624 };
12625 let (coordinator, mut reservation) = reconciliation_reservation();
12626 InferenceEngine::reconcile_worker_residency(
12627 &offload,
12628 "expected/model",
12629 &crate::offload::LocalWorkerResidency {
12630 model_id: "expected/model".into(),
12631 measured_weights_bytes: 2 * 1024 * 1024,
12632 },
12633 backend_cache::BackendRetention::Resident,
12634 &mut reservation,
12635 )
12636 .await
12637 .unwrap();
12638 drop(reservation);
12639 assert_eq!(
12640 *offload.calls.lock().unwrap(),
12641 vec!["allocation:expected/model".to_string()]
12642 );
12643 assert!(coordinator.is_resident("expected/model"));
12644 }
12645
12646 #[async_trait::async_trait]
12647 impl crate::offload::LocalGenerationOffload for FixtureLocalOffload {
12648 async fn generate(
12649 &self,
12650 _request: GenerateRequest,
12651 ) -> Result<InferenceResult, InferenceError> {
12652 unreachable!("streaming fixture")
12653 }
12654
12655 async fn stream(
12656 &self,
12657 _request: GenerateRequest,
12658 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
12659 unreachable!("admission-aware streaming fixture")
12660 }
12661
12662 async fn stream_admitted(
12663 &self,
12664 request: GenerateRequest,
12665 admission: crate::offload::LocalWorkerAdmission,
12666 ) -> Result<crate::offload::LocalOffloadStream, InferenceError> {
12667 let (tx, rx) = tokio::sync::mpsc::channel(4);
12668 let emit_done = self.emit_done;
12669 tokio::spawn(async move {
12670 let _ = tx.send(StreamEvent::TextDelta("local answer".into())).await;
12671 if emit_done {
12672 let _ = tx
12673 .send(StreamEvent::Done {
12674 text: "local answer".into(),
12675 tool_calls: vec![],
12676 })
12677 .await;
12678 }
12679 });
12680 Ok(crate::offload::LocalOffloadStream {
12681 events: rx,
12682 residency: crate::offload::LocalWorkerResidency {
12683 model_id: request.model.unwrap_or_else(|| "fixture/local".into()),
12684 measured_weights_bytes: admission.measured_weights_bytes,
12685 },
12686 retention: backend_cache::BackendRetention::Resident,
12687 })
12688 }
12689 }
12690
12691 fn pin_test_live_memory(engine: &mut InferenceEngine) {
12701 let policy = engine.local_admission.policy();
12702 engine.local_admission = Arc::new(resource_policy::LocalAdmissionCoordinator::with_probe(
12703 policy,
12704 HardwareInfo::detect(),
12705 Arc::new(resource_policy::FixedLiveMemoryProbe::known(1_048_576)),
12706 ));
12707 }
12708
12709 fn install_small_local_fixture(engine: &InferenceEngine) -> String {
12710 let schema = engine
12711 .unified_registry
12712 .find_by_name("Qwen3-0.6B")
12713 .expect("small built-in local model")
12714 .clone();
12715 let model_dir = engine.config.models_dir.join(&schema.name);
12716 std::fs::create_dir_all(&model_dir).unwrap();
12717 std::fs::write(model_dir.join("model.gguf"), b"fixture").unwrap();
12718 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
12719 schema.id
12720 }
12721
12722 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12723 fn install_exact_pin_equivalent_fixture(engine: &InferenceEngine) -> String {
12724 let gguf = engine
12725 .unified_registry
12726 .get("qwen/qwen3-0.6b:q8_0")
12727 .expect("GGUF fixture row")
12728 .clone();
12729 let mlx = engine
12730 .unified_registry
12731 .get("mlx/qwen3-0.6b:6bit")
12732 .expect("MLX equivalent fixture row")
12733 .clone();
12734
12735 let gguf_dir = engine.config.models_dir.join(&gguf.name);
12736 std::fs::create_dir_all(&gguf_dir).unwrap();
12737 std::fs::write(gguf_dir.join("model.gguf"), b"fixture").unwrap();
12738 std::fs::write(gguf_dir.join("tokenizer.json"), b"{}").unwrap();
12739
12740 let mlx_dir = engine.config.models_dir.join(&mlx.name);
12741 std::fs::create_dir_all(&mlx_dir).unwrap();
12742 std::fs::write(mlx_dir.join("config.json"), b"{}").unwrap();
12743 std::fs::write(mlx_dir.join("model.safetensors"), b"fixture").unwrap();
12744
12745 gguf.id
12746 }
12747
12748 fn remote_stream_fixture_schema(
12749 id: &str,
12750 endpoint: String,
12751 protocol: schema::ApiProtocol,
12752 api_key_env: &str,
12753 ) -> ModelSchema {
12754 ModelSchema {
12755 id: id.into(),
12756 name: "gemini-test".into(),
12757 provider: "test".into(),
12758 family: "test".into(),
12759 version: "1".into(),
12760 capabilities: vec![ModelCapability::Generate],
12761 context_length: 128_000,
12762 max_output_tokens: Some(8_192),
12763 param_count: String::new(),
12764 quantization: None,
12765 performance: Default::default(),
12766 cost: Default::default(),
12767 source: ModelSource::RemoteApi {
12768 endpoint,
12769 api_key_env: api_key_env.into(),
12770 api_key_envs: vec![],
12771 api_version: None,
12772 protocol,
12773 },
12774 tags: vec!["test".into()],
12775 supported_params: vec![],
12776 public_benchmarks: vec![],
12777 trust_tier: TrustTier::Community,
12778 deprecated: false,
12779 available: true,
12780 weights_ready: true,
12781 }
12782 }
12783
12784 async fn assert_remote_model_identity_contract(
12785 protocol: schema::ApiProtocol,
12786 model_id: &str,
12787 provider_model: &str,
12788 api_key_env: &str,
12789 ) {
12790 use wiremock::matchers::{method, path};
12791 use wiremock::{Mock, MockServer, ResponseTemplate};
12792
12793 let server = MockServer::start().await;
12794 let (endpoint_path, response) = match protocol {
12795 schema::ApiProtocol::OpenAiCompat => (
12796 "/v1/chat/completions",
12797 serde_json::json!({
12798 "choices": [{
12799 "message": {"content": "openai exact"},
12800 "finish_reason": "stop"
12801 }],
12802 "usage": {"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4}
12803 }),
12804 ),
12805 schema::ApiProtocol::Anthropic => (
12806 "/v1/messages",
12807 serde_json::json!({
12808 "content": [{"type": "text", "text": "anthropic exact"}],
12809 "stop_reason": "end_turn",
12810 "usage": {"input_tokens": 2, "output_tokens": 2}
12811 }),
12812 ),
12813 _ => unreachable!("identity regression covers the newsroom's two remote providers"),
12814 };
12815 Mock::given(method("POST"))
12816 .and(path(endpoint_path))
12817 .respond_with(ResponseTemplate::new(200).set_body_json(response))
12818 .mount(&server)
12819 .await;
12820 unsafe { std::env::set_var(api_key_env, "fixture") };
12821
12822 let tmp = TempDir::new().unwrap();
12823 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
12824 let mut schema =
12825 remote_stream_fixture_schema(model_id, server.uri(), protocol, api_key_env);
12826 schema.name = provider_model.into();
12827 engine.register_model(schema);
12828
12829 let mut exact_request = GenerateRequest {
12830 prompt: "return the exact identity".into(),
12831 ..Default::default()
12832 };
12833 pin_exact_model_id(&mut exact_request, model_id.into()).unwrap();
12834 let exact = engine.generate_tracked(exact_request).await.unwrap();
12835 assert_eq!(exact.model_used, model_id);
12836 assert_eq!(
12837 exact.model_identity.requested_model_id.as_deref(),
12838 Some(model_id)
12839 );
12840 assert_eq!(exact.model_identity.resolved_model_id, model_id);
12841 let exact_envelope = serde_json::to_value(&exact).unwrap();
12842 assert_eq!(exact_envelope["model_used"], model_id);
12843 assert_eq!(exact_envelope["requested_model_id"], model_id);
12844 assert_eq!(exact_envelope["resolved_model_id"], model_id);
12845 assert!(exact_envelope["row_digest"].is_string());
12846 assert!(exact_envelope["catalog_revision"].is_string());
12847
12848 let loose = engine
12849 .generate_tracked(GenerateRequest {
12850 prompt: "keep legacy display-name routing".into(),
12851 model: Some(provider_model.into()),
12852 params: GenerateParams {
12853 strict_model: true,
12854 ..Default::default()
12855 },
12856 ..Default::default()
12857 })
12858 .await
12859 .unwrap();
12860 assert_eq!(loose.model_used, provider_model);
12861 assert_eq!(loose.model_identity.requested_model_id, None);
12862 assert_eq!(loose.model_identity.resolved_model_id, model_id);
12863
12864 let requests = server.received_requests().await.unwrap();
12865 assert_eq!(requests.len(), 2);
12866 for request in requests {
12867 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
12868 assert_eq!(body["model"], provider_model);
12869 }
12870
12871 unsafe { std::env::remove_var(api_key_env) };
12872 }
12873
12874 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12880 #[tokio::test]
12881 async fn mlx_device_lock_is_singleton_and_serializes() {
12882 use std::sync::atomic::{AtomicUsize, Ordering};
12883 use std::sync::Arc;
12884 assert!(
12886 Arc::ptr_eq(
12887 &InferenceEngine::mlx_device_lock(),
12888 &InferenceEngine::mlx_device_lock()
12889 ),
12890 "device lock must be a process-wide singleton"
12891 );
12892 let inside = Arc::new(AtomicUsize::new(0));
12894 let peak = Arc::new(AtomicUsize::new(0));
12895 let mut handles = Vec::new();
12896 for _ in 0..8 {
12897 let inside = inside.clone();
12898 let peak = peak.clone();
12899 handles.push(tokio::spawn(async move {
12900 let _g = InferenceEngine::mlx_device_lock().lock_owned().await;
12901 let n = inside.fetch_add(1, Ordering::SeqCst) + 1;
12902 peak.fetch_max(n, Ordering::SeqCst);
12903 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
12904 inside.fetch_sub(1, Ordering::SeqCst);
12905 }));
12906 }
12907 for h in handles {
12908 h.await.unwrap();
12909 }
12910 assert_eq!(
12911 peak.load(Ordering::SeqCst),
12912 1,
12913 "at most one MLX device holder at a time"
12914 );
12915 }
12916
12917 #[test]
12922 fn auto_thinking_budget_gates_code_complex_and_capability() {
12923 assert_eq!(auto_thinking_budget(true, false, true), Some(24_000));
12925 assert_eq!(auto_thinking_budget(false, true, true), Some(8_000));
12927 assert_eq!(auto_thinking_budget(true, true, true), Some(24_000));
12929 assert_eq!(auto_thinking_budget(true, true, false), None);
12931 assert_eq!(auto_thinking_budget(false, false, true), None);
12933 }
12934
12935 #[test]
12936 fn is_explicit_code_intent_keys_on_caller_intent_not_keyword_classifier() {
12937 use crate::intent::{IntentHint, TaskHint};
12938 let code = IntentHint {
12940 task: Some(TaskHint::Code),
12941 ..Default::default()
12942 };
12943 assert!(is_explicit_code_intent(Some(&code)));
12944 assert!(!is_explicit_code_intent(None));
12949 let reasoning = IntentHint {
12951 task: Some(TaskHint::Reasoning),
12952 ..Default::default()
12953 };
12954 assert!(!is_explicit_code_intent(Some(&reasoning)));
12955 let unset = IntentHint {
12957 task: None,
12958 ..Default::default()
12959 };
12960 assert!(!is_explicit_code_intent(Some(&unset)));
12961 }
12962
12963 #[test]
12964 fn strict_model_suppresses_the_local_last_resort_append() {
12965 assert!(should_append_local_last_resort(false, false));
12967 assert!(!should_append_local_last_resort(false, true));
12970 assert!(!should_append_local_last_resort(true, false));
12973 assert!(!should_append_local_last_resort(true, true));
12974 }
12975
12976 #[test]
12977 fn last_resort_fallback_never_returns_an_unrunnable_apple_foundation() {
12978 let tmp = TempDir::new().unwrap();
12993 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
12994 if let Some(name) = engine.first_installed_local_model(false) {
12995 let runnable = engine
12996 .unified_registry
12997 .find_by_name(&name)
12998 .map(|s| !s.is_foundation_models() || s.available)
12999 .unwrap_or(false);
13000 assert!(
13001 runnable,
13002 "last-resort fallback returned a non-runnable model: {name}"
13003 );
13004 }
13005 }
13006
13007 #[test]
13008 fn empty_tool_catalog_is_no_tools() {
13009 let mut req = GenerateRequest {
13016 prompt: "p".into(),
13017 ..Default::default()
13018 };
13019 assert!(!InferenceEngine::request_has_tools(&req));
13020 req.tools = Some(vec![]);
13021 assert!(!InferenceEngine::request_has_tools(&req));
13022 req.tools = Some(vec![serde_json::json!({
13023 "name": "t", "description": "d", "parameters": {"type": "object"}
13024 })]);
13025 assert!(InferenceEngine::request_has_tools(&req));
13026 }
13027
13028 #[test]
13029 fn top_k_keeps_only_k_highest() {
13030 let mut probs = vec![0.1, 0.4, 0.2, 0.25, 0.05];
13032 InferenceEngine::apply_top_k_top_p(&mut probs, 2, 1.0);
13033 assert!(probs[0] == 0.0 && probs[2] == 0.0 && probs[4] == 0.0);
13035 assert!(probs[1] > 0.0 && probs[3] > 0.0);
13036 let sum: f32 = probs.iter().sum();
13037 assert!((sum - 1.0).abs() < 1e-5, "renormalized to 1.0, got {sum}");
13038 }
13039
13040 #[test]
13041 fn top_k_zero_is_a_noop() {
13042 let mut probs = vec![0.1, 0.4, 0.2, 0.3];
13043 let before = probs.clone();
13044 InferenceEngine::apply_top_k_top_p(&mut probs, 0, 1.0);
13045 assert_eq!(probs, before);
13046 }
13047
13048 #[test]
13049 fn top_p_nucleus_truncates_tail() {
13050 let mut probs = vec![0.6, 0.3, 0.07, 0.03];
13051 InferenceEngine::apply_top_k_top_p(&mut probs, 0, 0.9);
13052 assert!(probs[2] == 0.0 && probs[3] == 0.0);
13054 assert!(probs[0] > 0.0 && probs[1] > 0.0);
13055 }
13056
13057 #[test]
13058 fn truncate_at_stop_excludes_stop_sequence() {
13059 let stops = vec!["<|end|>".to_string(), "STOP".to_string()];
13060 assert_eq!(
13061 tasks::generate::truncate_at_stop("hello world<|end|>extra", &stops),
13062 "hello world"
13063 );
13064 assert_eq!(
13066 tasks::generate::truncate_at_stop("aSTOPb<|end|>c", &stops),
13067 "a"
13068 );
13069 assert_eq!(
13071 tasks::generate::truncate_at_stop("clean output", &stops),
13072 "clean output"
13073 );
13074 assert_eq!(
13076 tasks::generate::truncate_at_stop("text", &["".to_string()]),
13077 "text"
13078 );
13079 }
13080
13081 #[test]
13082 fn no_backend_hint_fires_on_missing_backend_phrases() {
13083 for phrase in [
13085 "no credential for proprietary provider 'parslee'",
13086 "model not found",
13087 "no models available",
13088 "model declares ModelSource::Delegated but no inference runner is registered",
13089 ] {
13090 let hint = no_backend_recovery_hint(phrase)
13091 .unwrap_or_else(|| panic!("expected a hint for {phrase:?}"));
13092 assert!(hint.contains("car models pull"));
13093 assert!(hint.contains("car auth login"));
13096 assert!(hint.contains(phrase));
13098 }
13099 }
13100
13101 #[test]
13109 fn credential_failure_is_matchable_as_data() {
13110 let expired = InferenceError::CredentialUnavailable {
13111 provider: "parslee".into(),
13112 model: "parslee/reasoning".into(),
13113 reason: CredentialFailure::Expired {
13114 expires_at: 1_754_257_929,
13115 },
13116 detail: "the Parslee token expired at unix 1754257929 and could not be refreshed"
13117 .into(),
13118 };
13119 let InferenceError::CredentialUnavailable { reason, .. } = &expired else {
13120 panic!("expected CredentialUnavailable");
13121 };
13122 assert_eq!(
13123 *reason,
13124 CredentialFailure::Expired {
13125 expires_at: 1_754_257_929
13126 },
13127 "a consumer must be able to branch on the expiry without parsing prose"
13128 );
13129 assert_ne!(
13133 CredentialFailure::SignedOut,
13134 CredentialFailure::StoreUnreadable
13135 );
13136 assert_ne!(
13137 CredentialFailure::SignedOut,
13138 CredentialFailure::Expired { expires_at: 0 }
13139 );
13140 assert_ne!(
13141 CredentialFailure::StoreUnreadable,
13142 CredentialFailure::RaceRetryable
13143 );
13144 }
13145
13146 #[test]
13156 fn typed_credential_error_still_satisfies_the_substring_classifiers() {
13157 for reason in [
13158 CredentialFailure::Expired { expires_at: 1 },
13159 CredentialFailure::SignedOut,
13160 CredentialFailure::StoreUnreadable,
13161 CredentialFailure::RaceRetryable,
13162 CredentialFailure::EnvVarMissing {
13163 env_var: "OPENAI_API_KEY".into(),
13164 },
13165 ] {
13166 let rendered = InferenceError::CredentialUnavailable {
13167 provider: "parslee".into(),
13168 model: "parslee/reasoning".into(),
13169 reason: reason.clone(),
13170 detail: "detail text".into(),
13171 }
13172 .to_string();
13173 assert!(
13175 rendered
13176 .to_ascii_lowercase()
13177 .contains("no credential for proprietary"),
13178 "classifier substring lost for {reason:?}: {rendered}"
13179 );
13180 assert!(rendered.contains("parslee/reasoning"), "{rendered}");
13182 assert!(rendered.contains("detail text"), "{rendered}");
13184 }
13185 }
13186
13187 #[test]
13188 fn auth_expired_hint_fires_on_auth_rejection_but_not_transient() {
13189 for phrase in [
13191 "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
13192 "HTTP 403: forbidden",
13193 "invalid_grant: The refresh token is invalid or expired",
13194 "token expired",
13195 ] {
13196 let hint = auth_expired_recovery_hint(phrase)
13197 .unwrap_or_else(|| panic!("expected an auth hint for {phrase:?}"));
13198 assert!(hint.contains("car auth login"));
13199 assert!(hint.contains(phrase));
13200 }
13201 assert!(auth_expired_recovery_hint("API returned 503: service unavailable").is_none());
13203 assert!(auth_expired_recovery_hint("request timed out").is_none());
13204 }
13205
13206 #[test]
13211 fn auth_rejection_classifier_matches_the_real_expired_token_error() {
13212 assert!(is_auth_rejection_message(
13213 "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
13214 Authentication required"
13215 ));
13216 for transient in [
13219 "API returned 503: service unavailable",
13220 "request timed out",
13221 "connection reset by peer",
13222 ] {
13223 assert!(
13224 !is_auth_rejection_message(transient),
13225 "transient wrongly classified as an auth rejection: {transient:?}"
13226 );
13227 }
13228 }
13229
13230 #[test]
13238 fn typed_errors_classify_by_their_structure_not_their_prose() {
13239 use FallbackReason as R;
13240 let cases: Vec<(InferenceError, R)> = vec![
13241 (
13244 InferenceError::ProviderAccount {
13245 provider: "openai".into(),
13246 status: 401,
13247 message: "provider rejected the API key — check the configured credential"
13248 .into(),
13249 },
13250 R::CredentialRejected,
13251 ),
13252 (
13254 InferenceError::ProviderAccount {
13255 provider: "openrouter".into(),
13256 status: 402,
13257 message: "OpenRouter account is out of credits".into(),
13258 },
13259 R::QuotaExhausted,
13261 ),
13262 (
13263 InferenceError::CredentialUnavailable {
13264 provider: "parslee".into(),
13265 model: "parslee/reasoning".into(),
13266 reason: CredentialFailure::Expired { expires_at: 0 },
13267 detail: "session expired".into(),
13268 },
13269 R::CredentialRejected,
13270 ),
13271 (
13272 InferenceError::CredentialUnavailable {
13273 provider: "openai".into(),
13274 model: "openai/gpt-5.6".into(),
13275 reason: CredentialFailure::EnvVarMissing {
13276 env_var: "OPENAI_API_KEY".into(),
13277 },
13278 detail: "not set".into(),
13279 },
13280 R::CredentialAbsent,
13281 ),
13282 (
13285 InferenceError::CredentialUnavailable {
13286 provider: "parslee".into(),
13287 model: "parslee/reasoning".into(),
13288 reason: CredentialFailure::StoreUnreadable,
13289 detail: "the bounded Keychain helper timed out".into(),
13290 },
13291 R::Failed,
13292 ),
13293 (
13294 InferenceError::Transient {
13295 status: Some(429),
13296 message: "slow down".into(),
13297 },
13298 R::RateLimited,
13299 ),
13300 (
13305 InferenceError::Transient {
13306 status: None,
13307 message: "connection reset by peer".into(),
13308 },
13309 R::Failed,
13310 ),
13311 (
13312 InferenceError::Transient {
13313 status: Some(503),
13314 message: "upstream down".into(),
13315 },
13316 R::Failed,
13317 ),
13318 ];
13319 for (err, want) in cases {
13320 assert_eq!(classify_fallback_reason(&err), want, "{err}");
13321 }
13322 }
13323
13324 #[test]
13332 fn a_quoted_status_in_a_provider_body_does_not_decide_the_bucket() {
13333 use FallbackReason as R;
13334 for (msg, want) in [
13335 (
13336 "API returned 400 Bad Request: your last request 401'd upstream and was unauthorized",
13337 R::Failed,
13338 ),
13339 ("API returned 400 Bad Request: timeout param invalid", R::Failed),
13340 ("API returned 429 Too Many Requests: slow down", R::RateLimited),
13341 ("API returned 401 Unauthorized: bad key", R::CredentialRejected),
13342 ] {
13343 assert_eq!(
13344 classify_fallback_reason(&InferenceError::InferenceFailed(msg.into())),
13345 want,
13346 "{msg}"
13347 );
13348 }
13349 }
13350
13351 #[test]
13355 fn a_parslee_org_lookup_failure_is_classified_by_its_status() {
13356 use FallbackReason as R;
13357 for (msg, want) in [
13358 (
13359 "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
13360 R::CredentialRejected,
13361 ),
13362 (
13363 "Parslee org lookup failed: HTTP 429 Too Many Requests: slow down",
13364 R::RateLimited,
13365 ),
13366 (
13367 "Parslee org lookup failed: HTTP 500 Internal Server Error: boom",
13368 R::Failed,
13369 ),
13370 ] {
13371 assert_eq!(
13372 classify_fallback_reason(&InferenceError::InferenceFailed(msg.into())),
13373 want,
13374 "{msg}"
13375 );
13376 }
13377 }
13378
13379 #[test]
13383 fn every_skipped_lane_is_recorded_in_order() {
13384 let mut hops = Vec::new();
13385 for (cand, err) in [
13386 (
13387 "lane-one",
13388 InferenceError::Transient {
13389 status: Some(429),
13390 message: "x".into(),
13391 },
13392 ),
13393 (
13396 "lane-two",
13397 InferenceError::Transient {
13398 status: None,
13399 message: "connection refused".into(),
13400 },
13401 ),
13402 (
13403 "lane-three",
13404 InferenceError::ProviderAccount {
13405 provider: "openai".into(),
13406 status: 401,
13407 message: "rejected".into(),
13408 },
13409 ),
13410 ] {
13411 record_fallback_from(&mut hops, cand, &err);
13412 }
13413 assert_eq!(
13414 hops.iter()
13415 .map(|h| h.candidate.as_str())
13416 .collect::<Vec<_>>(),
13417 ["lane-one", "lane-two", "lane-three"]
13418 );
13419 assert_eq!(
13420 hops.iter().map(|h| h.reason).collect::<Vec<_>>(),
13421 [
13422 FallbackReason::RateLimited,
13423 FallbackReason::Failed,
13424 FallbackReason::CredentialRejected
13425 ]
13426 );
13427 }
13428
13429 #[test]
13437 fn a_refused_api_key_is_journaled_but_does_not_claim_sign_in_fixes_it() {
13438 let refused = InferenceError::ProviderAccount {
13439 provider: "openai".into(),
13440 status: 401,
13441 message: "provider rejected the API key — check the configured credential".into(),
13442 };
13443 let mut hops = Vec::new();
13444 record_fallback_from(&mut hops, "openai/gpt-5.6", &refused);
13445 assert_eq!(hops[0].reason, FallbackReason::CredentialRejected);
13446
13447 let mut auth = None;
13449 record_auth_dead_lane(&mut auth, "openai/gpt-5.6", &refused.to_string());
13450 assert_eq!(auth, None, "car auth login does not fix a bad OpenAI key");
13451 }
13452
13453 #[test]
13454 fn auth_dead_lane_records_first_rejected_candidate_only() {
13455 let mut slot: Option<String> = None;
13457 record_auth_dead_lane(
13458 &mut slot,
13459 "parslee/reasoning",
13460 "API returned 503: unavailable",
13461 );
13462 record_auth_dead_lane(&mut slot, "openai/gpt-5.6", "request timed out");
13463 assert_eq!(slot, None);
13464
13465 record_auth_dead_lane(
13467 &mut slot,
13468 "parslee/reasoning",
13469 "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
13470 );
13471 assert_eq!(slot.as_deref(), Some("parslee/reasoning"));
13472
13473 record_auth_dead_lane(&mut slot, "anthropic/claude", "HTTP 403: forbidden");
13476 assert_eq!(slot.as_deref(), Some("parslee/reasoning"));
13477 }
13478
13479 #[test]
13480 fn configured_provider_with_expired_token_is_named_before_a_local_oom() {
13481 let expired = InferenceError::CredentialUnavailable {
13482 provider: "parslee".into(),
13483 model: "parslee/reasoning".into(),
13484 reason: CredentialFailure::Expired { expires_at: 42 },
13485 detail: "access token expired".into(),
13486 };
13487 let mut credential = None;
13488 record_route_credential_failure(&mut credential, "parslee/reasoning", &expired, false);
13489
13490 let error = apply_route_failure_context(
13491 InferenceError::InferenceFailed(
13492 "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
13493 .into(),
13494 ),
13495 credential.as_ref(),
13496 )
13497 .to_string();
13498
13499 let auth_pos = error
13500 .find("Parslee login expired")
13501 .expect("expired login must be named");
13502 let remedy_pos = error
13503 .find("car auth login")
13504 .expect("credential remedy must be named");
13505 let oom_pos = error
13506 .find("9059 MB")
13507 .expect("fallback error must remain as secondary detail");
13508 assert!(auth_pos < remedy_pos && remedy_pos < oom_pos, "{error}");
13509 assert!(is_auth_failure_message(&error), "{error}");
13510 }
13511
13512 #[test]
13513 fn absent_login_is_named_before_a_local_oom() {
13514 let failure = parslee_signed_out_route_failure();
13515 let error = apply_route_failure_context(
13516 InferenceError::InferenceFailed("local fallback needs 9059 MB".into()),
13517 Some(&failure),
13518 )
13519 .to_string();
13520
13521 let absent_pos = error
13522 .find("Parslee login is absent")
13523 .expect("missing login must be named");
13524 let remedy_pos = error
13525 .find("car auth login")
13526 .expect("credential remedy must be named");
13527 let oom_pos = error
13528 .find("9059 MB")
13529 .expect("fallback OOM must remain as secondary detail");
13530 assert!(absent_pos < remedy_pos && remedy_pos < oom_pos, "{error}");
13531 }
13532
13533 #[test]
13534 fn genuine_local_oom_is_not_reclassified_as_auth() {
13535 let oom = InferenceError::InferenceFailed(
13536 "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
13537 .into(),
13538 );
13539 let error = apply_route_failure_context(oom, None);
13540 assert!(matches!(error, InferenceError::InferenceFailed(ref message)
13541 if message.starts_with("This model needs about 9059 MB")));
13542 }
13543
13544 #[test]
13545 fn unconfigured_provider_is_not_surfaced_over_the_terminal_failure() {
13546 let mut credential = None;
13547 record_route_credential_failure(
13548 &mut credential,
13549 "openai/gpt-5.6",
13550 &InferenceError::CredentialUnavailable {
13551 provider: "openai".into(),
13552 model: "openai/gpt-5.6".into(),
13553 reason: CredentialFailure::EnvVarMissing {
13554 env_var: "OPENAI_API_KEY".into(),
13555 },
13556 detail: "set OPENAI_API_KEY".into(),
13557 },
13558 false,
13559 );
13560
13561 let terminal = InferenceError::InferenceFailed(
13562 "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
13563 .into(),
13564 );
13565 let error = apply_route_failure_context(terminal, credential.as_ref()).to_string();
13566 assert!(
13567 credential.is_none(),
13568 "an unconfigured fallback is ambient noise"
13569 );
13570 assert!(error.contains("9059 MB"), "{error}");
13571 assert!(!error.contains("OPENAI_API_KEY"), "{error}");
13572 assert!(!is_auth_failure_message(&error), "{error}");
13573 }
13574
13575 #[test]
13576 fn explicitly_requested_missing_credential_uses_the_shared_auth_table() {
13577 let mut credential = None;
13578 record_route_credential_failure(
13579 &mut credential,
13580 "openai/gpt-5.6",
13581 &InferenceError::CredentialUnavailable {
13582 provider: "openai".into(),
13583 model: "openai/gpt-5.6".into(),
13584 reason: CredentialFailure::EnvVarMissing {
13585 env_var: "OPENAI_API_KEY".into(),
13586 },
13587 detail: "set OPENAI_API_KEY".into(),
13588 },
13589 true,
13590 );
13591 let summary = credential
13592 .expect("an explicitly requested provider must surface its missing key")
13593 .summary;
13594 assert!(summary.contains(AUTH_ENV_MISSING_MARKER), "{summary}");
13595 assert!(is_auth_failure_message(&summary), "{summary}");
13596 }
13597
13598 #[test]
13599 fn latest_actionable_credential_failure_wins() {
13600 let mut credential = None;
13601 record_route_credential_failure(
13602 &mut credential,
13603 "parslee/reasoning",
13604 &InferenceError::ProviderAccount {
13605 provider: "parslee".into(),
13606 status: 401,
13607 message: "Unauthorized".into(),
13608 },
13609 false,
13610 );
13611 record_route_credential_failure(
13612 &mut credential,
13613 "anthropic/claude",
13614 &InferenceError::CredentialUnavailable {
13615 provider: "anthropic".into(),
13616 model: "anthropic/claude".into(),
13617 reason: CredentialFailure::Expired { expires_at: 43 },
13618 detail: "configured token expired".into(),
13619 },
13620 false,
13621 );
13622
13623 assert_eq!(
13624 credential
13625 .expect("latest actionable credential cause must be retained")
13626 .summary,
13627 "anthropic login expired for `anthropic/claude` — run `car auth login`"
13628 );
13629 }
13630
13631 #[test]
13632 fn store_unreadable_summary_uses_the_shared_auth_table() {
13633 let summary = route_credential_failure(
13634 "parslee/reasoning",
13635 &InferenceError::CredentialUnavailable {
13636 provider: "parslee".into(),
13637 model: "parslee/reasoning".into(),
13638 reason: CredentialFailure::StoreUnreadable,
13639 detail: "keychain helper timed out".into(),
13640 },
13641 false,
13642 )
13643 .expect("an unreadable configured credential store is actionable");
13644 assert!(summary.contains(AUTH_STORE_UNREADABLE_MARKER), "{summary}");
13645 assert!(is_auth_failure_message(&summary), "{summary}");
13646 }
13647
13648 fn chain_gate_fixture_schema(id: &str, provider: &str, source: ModelSource) -> ModelSchema {
13649 ModelSchema {
13650 id: id.into(),
13651 name: id.into(),
13652 provider: provider.into(),
13653 family: "test".into(),
13654 version: "1".into(),
13655 capabilities: vec![ModelCapability::Generate],
13656 context_length: 32_768,
13657 max_output_tokens: Some(4_096),
13658 param_count: String::new(),
13659 quantization: None,
13660 performance: Default::default(),
13661 cost: Default::default(),
13662 source,
13663 tags: vec![],
13664 supported_params: vec![],
13665 public_benchmarks: vec![],
13666 trust_tier: TrustTier::Community,
13667 deprecated: false,
13668 available: true,
13669 weights_ready: true,
13670 }
13671 }
13672
13673 #[test]
13674 fn signed_out_pre_seed_is_gated_on_a_parslee_route_in_the_chain() {
13675 let local = chain_gate_fixture_schema(
13676 "qwen/qwen3-4b:q4_k_m",
13677 "qwen",
13678 ModelSource::Mlx {
13679 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
13680 hf_weight_file: None,
13681 },
13682 );
13683 let ollama = chain_gate_fixture_schema(
13684 "ollama/llama3",
13685 "ollama",
13686 ModelSource::Ollama {
13687 model_tag: "llama3".into(),
13688 host: "http://localhost:11434".into(),
13689 },
13690 );
13691 let parslee = chain_gate_fixture_schema(
13692 "parslee/reasoning",
13693 "parslee",
13694 ModelSource::Proprietary {
13695 provider: "parslee".into(),
13696 endpoint: "https://api.parslee.ai".into(),
13697 auth: ProprietaryAuth::OAuth2Pkce {
13698 authority: "https://login.example".into(),
13699 client_id: "client".into(),
13700 scopes: vec![],
13701 },
13702 protocol: Default::default(),
13703 },
13704 );
13705 let cloud = chain_gate_fixture_schema(
13706 "openai/gpt-5.6",
13707 "openai",
13708 ModelSource::RemoteApi {
13709 endpoint: "https://api.openai.com".into(),
13710 api_key_env: "OPENAI_API_KEY".into(),
13711 api_key_envs: vec![],
13712 api_version: None,
13713 protocol: schema::ApiProtocol::OpenAiCompat,
13714 },
13715 );
13716 let schemas: std::collections::HashMap<&str, &ModelSchema> = [
13717 ("qwen/qwen3-4b:q4_k_m", &local),
13718 ("ollama/llama3", &ollama),
13719 ("parslee/reasoning", &parslee),
13720 ("openai/gpt-5.6", &cloud),
13721 ]
13722 .into_iter()
13723 .collect();
13724 let resolve = |m: &str| schemas.get(m).copied();
13725
13726 let local_only = vec![
13728 "qwen/qwen3-4b:q4_k_m".to_string(),
13729 "ollama/llama3".to_string(),
13730 ];
13731 assert!(!chain_includes_parslee_route(resolve, &local_only));
13732
13733 let with_parslee = vec![
13735 "parslee/reasoning".to_string(),
13736 "qwen/qwen3-4b:q4_k_m".to_string(),
13737 ];
13738 assert!(chain_includes_parslee_route(resolve, &with_parslee));
13739 let with_cloud = vec![
13740 "openai/gpt-5.6".to_string(),
13741 "qwen/qwen3-4b:q4_k_m".to_string(),
13742 ];
13743 assert!(!chain_includes_parslee_route(resolve, &with_cloud));
13747 let mut credential = chain_includes_parslee_route(resolve, &with_cloud)
13748 .then(parslee_signed_out_route_failure);
13749 let outage = InferenceError::Transient {
13750 status: Some(500),
13751 message: "OpenAI HTTP 500 Internal Server Error".into(),
13752 };
13753 record_route_credential_failure(&mut credential, &with_cloud[0], &outage, false);
13754 let terminal = apply_route_failure_context(outage, credential.as_ref());
13755 assert!(matches!(terminal, InferenceError::Transient { .. }));
13756 assert!(!is_auth_failure_message(&terminal.to_string()));
13757 let oom = InferenceError::InferenceFailed("local model out of memory".into());
13758 record_route_credential_failure(&mut credential, &with_cloud[1], &oom, false);
13759 let terminal = apply_route_failure_context(oom, credential.as_ref()).to_string();
13760 assert!(!terminal.contains("Parslee"), "{terminal}");
13761 assert!(!is_auth_failure_message(&terminal), "{terminal}");
13762
13763 record_route_credential_failure(
13765 &mut credential,
13766 &with_cloud[0],
13767 &InferenceError::ProviderAccount {
13768 provider: "openai".into(),
13769 status: 401,
13770 message: "invalid API key".into(),
13771 },
13772 false,
13773 );
13774 let terminal = apply_route_failure_context(
13775 InferenceError::InferenceFailed("local model out of memory".into()),
13776 credential.as_ref(),
13777 )
13778 .to_string();
13779 assert!(terminal.contains("openai"), "{terminal}");
13780 assert!(!terminal.contains("Parslee"), "{terminal}");
13781 assert!(is_auth_failure_message(&terminal), "{terminal}");
13782
13783 let unknown = vec!["missing/model".to_string()];
13785 assert!(!chain_includes_parslee_route(resolve, &unknown));
13786 }
13787
13788 #[test]
13792 fn local_only_terminal_failures_are_not_relabeled_as_auth() {
13793 let cases = [
13794 InferenceError::InferenceFailed(
13796 "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
13797 .into(),
13798 ),
13799 InferenceError::Transient {
13801 status: Some(500),
13802 message: "HTTP 500 Internal Server Error".into(),
13803 },
13804 InferenceError::InferenceFailed("llama runner process has terminated".into()),
13806 ];
13807 for case in cases {
13808 let error = apply_route_failure_context(case, None).to_string();
13809 assert!(!error.contains("Parslee login is absent"), "{error}");
13810 assert!(!is_auth_failure_message(&error), "{error}");
13811 }
13812
13813 let error = apply_route_failure_context(
13817 InferenceError::ModelNotFound("qwen/qwen3-4b:q4_k_m".into()),
13818 None,
13819 )
13820 .to_string();
13821 assert!(!error.contains("Parslee login is absent"), "{error}");
13822 assert!(error.contains("car models pull"), "{error}");
13823 }
13824
13825 #[test]
13828 fn credential_context_preserves_the_typed_terminal_variant() {
13829 let credential = parslee_signed_out_route_failure();
13830
13831 let transient = apply_route_failure_context(
13832 InferenceError::Transient {
13833 status: Some(500),
13834 message: "HTTP 500 Internal Server Error".into(),
13835 },
13836 Some(&credential),
13837 );
13838 match &transient {
13839 InferenceError::Transient { status, message } => {
13840 assert_eq!(*status, Some(500));
13841 assert!(message.starts_with("Parslee login is absent"), "{message}");
13842 assert!(message.contains("HTTP 500"), "{message}");
13843 }
13844 other => panic!("Transient must stay Transient, got {other:?}"),
13845 }
13846
13847 let account = apply_route_failure_context(
13848 InferenceError::ProviderAccount {
13849 provider: "openai".into(),
13850 status: 402,
13851 message: "insufficient credits".into(),
13852 },
13853 Some(&credential),
13854 );
13855 assert!(
13856 matches!(
13857 &account,
13858 InferenceError::ProviderAccount {
13859 provider,
13860 status: 402,
13861 ..
13862 } if provider == "openai"
13863 ),
13864 "ProviderAccount must stay ProviderAccount, got {account:?}"
13865 );
13866
13867 let unavailable = apply_route_failure_context(
13868 InferenceError::CredentialUnavailable {
13869 provider: "parslee".into(),
13870 model: "parslee/reasoning".into(),
13871 reason: CredentialFailure::Expired { expires_at: 42 },
13872 detail: "access token expired".into(),
13873 },
13874 Some(&credential),
13875 );
13876 match &unavailable {
13877 InferenceError::CredentialUnavailable { reason, detail, .. } => {
13878 assert_eq!(*reason, CredentialFailure::Expired { expires_at: 42 });
13879 assert!(detail.contains("Parslee login is absent"), "{detail}");
13880 }
13881 other => panic!("CredentialUnavailable must keep its reason data, got {other:?}"),
13882 }
13883 assert!(is_auth_failure_message(&unavailable.to_string()));
13884 }
13885
13886 #[test]
13889 fn non_parslee_rejection_summaries_match_the_shared_classifier() {
13890 let rejected = route_credential_failure(
13891 "openai/gpt-5.6",
13892 &InferenceError::ProviderAccount {
13893 provider: "openai".into(),
13894 status: 403,
13895 message: "key revoked".into(),
13896 },
13897 false,
13898 )
13899 .expect("a 403 from a configured provider is actionable");
13900 assert!(rejected.contains("credential was rejected"), "{rejected}");
13901 assert!(is_auth_failure_message(&rejected), "{rejected}");
13902
13903 let generic = route_credential_failure(
13904 "openai/gpt-5.6",
13905 &InferenceError::InferenceFailed("upstream said: token expired".into()),
13906 false,
13907 )
13908 .expect("an auth-rejection message from a non-Parslee route is actionable");
13909 assert!(generic.contains("repair its provider login"), "{generic}");
13910 assert!(is_auth_failure_message(&generic), "{generic}");
13911 }
13912
13913 #[test]
13916 fn fresh_install_exhaustion_keeps_the_models_pull_guidance() {
13917 let credential = parslee_signed_out_route_failure();
13918 let error = apply_route_failure_context(
13919 InferenceError::InferenceFailed("no models available for generate".into()),
13920 Some(&credential),
13921 )
13922 .to_string();
13923 assert!(error.contains("Parslee login is absent"), "{error}");
13924 assert!(
13925 error.contains("car models pull qwen/qwen3-4b:q4_k_m"),
13926 "{error}"
13927 );
13928 assert!(error.contains("car auth login"), "{error}");
13929 }
13930
13931 #[test]
13934 fn auth_fallback_from_round_trips_and_defaults_to_none() {
13935 let mut result: InferenceResult = serde_json::from_value(serde_json::json!({
13936 "text": "hi",
13937 "tool_calls": [],
13938 "trace_id": "t",
13939 "model_used": "openai/gpt-5.6",
13940 "latency_ms": 1,
13941 }))
13942 .expect("a payload without the field still deserializes");
13943 assert_eq!(result.auth_fallback_from, None);
13944 let json = serde_json::to_value(&result).unwrap();
13945 assert!(json.get("auth_fallback_from").is_none());
13946
13947 result.auth_fallback_from = Some("parslee/reasoning".to_string());
13948 let json = serde_json::to_value(&result).unwrap();
13949 assert_eq!(json["auth_fallback_from"], "parslee/reasoning");
13950 let back: InferenceResult = serde_json::from_value(json).unwrap();
13951 assert_eq!(
13952 back.auth_fallback_from.as_deref(),
13953 Some("parslee/reasoning")
13954 );
13955 }
13956
13957 #[test]
13962 fn local_last_resort_turn_carries_model_id_and_flag() {
13963 let ordinary: InferenceResult = serde_json::from_value(serde_json::json!({
13964 "text": "hi",
13965 "tool_calls": [],
13966 "trace_id": "ordinary",
13967 "model_used": "anthropic/claude-haiku-4-5:latest",
13968 "latency_ms": 1,
13969 }))
13970 .expect("older payloads default the marker");
13971 assert!(!ordinary.local_last_resort);
13972
13973 let fallback: InferenceResult = serde_json::from_value(serde_json::json!({
13974 "text": "offline answer",
13975 "tool_calls": [],
13976 "trace_id": "fallback",
13977 "model_used": "Qwen3 4B MLX",
13978 "resolved_model_id": "mlx/qwen3-4b:4bit",
13979 "latency_ms": 1,
13980 "local_last_resort": true,
13981 }))
13982 .expect("fallback attribution payload");
13983 assert_eq!(fallback.served_model_id(), "mlx/qwen3-4b:4bit");
13984 assert!(fallback.local_last_resort);
13985
13986 assert!(!is_local_last_resort(None, "mlx/qwen3-4b:4bit"));
13987 assert!(report_local_last_resort_served(
13988 Some("mlx/qwen3-4b:4bit"),
13989 "mlx/qwen3-4b:4bit",
13990 "mlx/qwen3-4b:4bit"
13991 ));
13992 assert!(!is_local_last_resort(
13993 Some("mlx/qwen3-4b:4bit"),
13994 "anthropic/claude-haiku-4-5:latest"
13995 ));
13996 }
13997
13998 #[test]
13999 fn no_backend_hint_passes_through_transient_errors() {
14000 for phrase in [
14003 "API returned 401 Unauthorized",
14004 "API returned 429 Too Many Requests",
14005 "API returned 500 Internal Server Error",
14006 "connection refused",
14007 "request timed out",
14008 "parse response: unexpected end of input",
14009 ] {
14010 assert!(
14011 no_backend_recovery_hint(phrase).is_none(),
14012 "transient error wrongly classified as no-backend: {phrase:?}"
14013 );
14014 }
14015 }
14016
14017 static ENV_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
14020
14021 #[cfg(unix)]
14022 #[tokio::test]
14023 async fn exact_codex_subscription_row_dispatches_without_an_openai_key() {
14024 use std::os::unix::fs::PermissionsExt;
14025
14026 let _environment = ENV_MUTEX.lock().await;
14027 let _restore = RestoredEnvironment::capture(&["CAR_CODEX_BIN", "OPENAI_API_KEY"]);
14028 let tmp = TempDir::new().unwrap();
14029 let fixture = tmp.path().join("codex-fixture.sh");
14030 std::fs::write(
14031 &fixture,
14032 r#"#!/bin/sh
14033if [ -n "${OPENAI_API_KEY-}" ]; then
14034 echo 'OPENAI_API_KEY leaked' >&2
14035 exit 91
14036fi
14037cat >/dev/null
14038printf '%s\n' '{"type":"turn.started"}'
14039printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture newsroom answer"}}'
14040printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":17,"output_tokens":5}}'
14041"#,
14042 )
14043 .unwrap();
14044 std::fs::set_permissions(&fixture, std::fs::Permissions::from_mode(0o700)).unwrap();
14045 unsafe {
14046 std::env::set_var("CAR_CODEX_BIN", &fixture);
14047 std::env::set_var("OPENAI_API_KEY", "must-not-reach-codex");
14048 }
14049
14050 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
14051 let mut request = GenerateRequest {
14052 prompt: "write a brief".into(),
14053 params: GenerateParams {
14054 max_tokens: 1056,
14055 strict_model: true,
14056 ..Default::default()
14057 },
14058 ..Default::default()
14059 };
14060 pin_exact_model_id(&mut request, "openai/gpt-5.6-sol:high".into()).unwrap();
14061 let result = engine.generate_tracked(request).await.unwrap();
14062
14063 assert_eq!(result.model_used, "openai/gpt-5.6-sol:high");
14064 assert_eq!(result.text, "fixture newsroom answer");
14065 assert_eq!(result.usage.as_ref().unwrap().total_tokens, 22);
14066 assert_eq!(result.stop_reason, None, "Codex reports no finish reason");
14067 assert!(result.tool_calls.is_empty());
14068 }
14069
14070 #[tokio::test]
14071 async fn isolated_test_child_removes_parent_auth_lock_override() {
14072 const CHILD_SENTINEL: &str = "CAR_AUTH_LOCK_SANITIZER_CHILD";
14073 if std::env::var_os(CHILD_SENTINEL).is_some() {
14074 assert!(
14075 std::env::var_os("CAR_AUTH_LOCK_PATH").is_none(),
14076 "an isolated credential test must not inherit the invoking process's auth lock"
14077 );
14078 return;
14079 }
14080
14081 let _environment = ENV_MUTEX.lock().await;
14082 let _restore = RestoredEnvironment::capture(&["CAR_AUTH_LOCK_PATH"]);
14083 unsafe {
14084 std::env::set_var(
14085 "CAR_AUTH_LOCK_PATH",
14086 "/sentinel/must-not-reach-isolated-child.lock",
14087 );
14088 }
14089 assert!(!crate::run_in_isolated_test_process(
14090 "tests::isolated_test_child_removes_parent_auth_lock_override",
14091 CHILD_SENTINEL,
14092 ));
14093 }
14094
14095 fn test_config(models_dir: PathBuf) -> InferenceConfig {
14096 let state_root = models_dir
14100 .parent()
14101 .map(Path::to_path_buf)
14102 .unwrap_or_else(|| models_dir.clone());
14103 InferenceConfig {
14104 models_dir,
14105 state_root,
14106 device: None,
14107 generation_model: "Qwen3-0.6B".into(),
14108 preferred_generation_model: None,
14109 embedding_model: "Qwen3-Embedding-0.6B".into(),
14110 preferred_embedding_model: None,
14111 classification_model: "Qwen3-0.6B".into(),
14112 preferred_classification_model: None,
14113 }
14114 }
14115
14116 fn metal_mac_for_fit(ram_gb: u64) -> HardwareInfo {
14117 HardwareInfo {
14118 os: "macos".into(),
14119 arch: "aarch64".into(),
14120 cpu_cores: 8,
14121 total_ram_mb: ram_gb * 1024,
14122 gpu_backend: crate::hardware::GpuBackend::Metal,
14123 gpu_memory_mb: None,
14124 gpu_devices: vec![],
14125 recommended_model: String::new(),
14126 recommended_context: 8_192,
14127 max_model_mb: 0,
14128 }
14129 }
14130
14131 #[test]
14138 fn unified_rows_carry_fit_per_machine_and_keep_deprecated_rows() {
14139 let _credential_scope = crate::openrouter::test_credential_scope();
14146 crate::openrouter::set_test_credential(Some("unified-rows-fit-test-key"));
14147 let root = tempfile::tempdir().unwrap();
14148 let config = test_config(root.path().join("weights"));
14149 let mut engine = InferenceEngine::new(config);
14150 let mut retired = engine
14151 .list_schemas()
14152 .into_iter()
14153 .find(|schema| schema.id == "mlx/qwen3-4b:4bit")
14154 .expect("builtin 4B MLX row");
14155 retired.id = "test/retired-4b:4bit".into();
14156 retired.name = "Retired 4B".into();
14157 retired.deprecated = true;
14158 engine.register_model(retired);
14159 let policy = resource_policy::ResourcePolicy::everyday();
14160
14161 let at_8 = engine.list_models_unified_for(&metal_mac_for_fit(8), &policy);
14162 let at_32 = engine.list_models_unified_for(&metal_mac_for_fit(32), &policy);
14163 let ids = |rows: &[ModelInfo]| rows.iter().map(|row| row.id.clone()).collect::<Vec<_>>();
14164 assert_eq!(
14165 ids(&at_8),
14166 ids(&at_32),
14167 "the machine never removes or reorders a row"
14168 );
14169 let row = |rows: &[ModelInfo], id: &str| {
14170 rows.iter()
14171 .find(|row| row.id == id)
14172 .unwrap_or_else(|| panic!("{id} missing"))
14173 .clone()
14174 };
14175
14176 let eight_b_small = row(&at_8, "mlx/qwen3-8b:4bit");
14177 let eight_b_large = row(&at_32, "mlx/qwen3-8b:4bit");
14178 assert_eq!(eight_b_small.fit, ModelFitStatus::TooBig);
14179 assert_eq!(eight_b_large.fit, ModelFitStatus::Fits);
14180 assert!(eight_b_small.platform_compatible && eight_b_large.platform_compatible);
14181 assert_eq!(
14182 eight_b_small.estimated_peak_mb, eight_b_large.estimated_peak_mb,
14183 "the estimate is the model's; only the budget differs"
14184 );
14185 assert!(eight_b_small.estimated_peak_mb.is_some_and(|mb| mb > 4_800));
14186 assert_eq!(eight_b_small.family.as_deref(), Some("qwen3"));
14187 assert!(eight_b_small.version.is_some());
14188
14189 let retired = row(&at_8, "test/retired-4b:4bit");
14190 assert!(retired.deprecated, "deprecated rows stay listed, flagged");
14191 assert!(!eight_b_small.deprecated);
14192
14193 let mut saw_remote = false;
14194 for remote in at_8.iter().filter(|row| !row.is_local) {
14195 saw_remote = true;
14196 assert_eq!(remote.fit, ModelFitStatus::Fits, "{}", remote.id);
14197 assert!(remote.platform_compatible, "{}", remote.id);
14198 assert_eq!(remote.estimated_peak_mb, None, "{}", remote.id);
14199 assert_eq!(
14200 remote.family, None,
14201 "{}: no upstream identifier here",
14202 remote.id
14203 );
14204 assert_eq!(remote.version, None, "{}", remote.id);
14205 }
14206 assert!(saw_remote);
14207
14208 for (small, large) in at_8.iter().zip(&at_32) {
14211 let strip = |row: &ModelInfo| {
14212 let mut value = serde_json::to_value(row).unwrap();
14213 let object = value.as_object_mut().unwrap();
14214 for key in ["fit", "estimated_peak_mb", "platform_compatible"] {
14215 object.remove(key);
14216 }
14217 value
14218 };
14219 assert_eq!(strip(small), strip(large), "{}", small.id);
14220 }
14221 }
14222
14223 #[tokio::test]
14224 async fn explicit_model_blocked_by_zero_budget_does_not_substitute_or_download() {
14225 let root = tempfile::tempdir().unwrap();
14226 let models_dir = root.path().join("weights");
14227 let config = test_config(models_dir.clone());
14228 let repository =
14229 crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
14230 crate::resource_policy::ResourcePolicyRepository::save(
14231 &repository,
14232 &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
14233 )
14234 .unwrap();
14235 let engine = InferenceEngine::new(config);
14236
14237 let error = engine
14238 .generate_tracked(GenerateRequest {
14239 prompt: "hello".into(),
14240 model: Some("mlx/qwen3-4b:4bit".into()),
14241 ..Default::default()
14242 })
14243 .await
14244 .unwrap_err();
14245
14246 assert!(matches!(
14247 error,
14248 InferenceError::LocalResourceBlocked {
14249 preflight: crate::resource_policy::LocalLoadPreflight {
14250 verdict: crate::resource_policy::LocalLoadVerdict::DisabledByPolicy,
14251 ..
14252 },
14253 ..
14254 }
14255 ));
14256 assert!(
14257 !models_dir.exists() || std::fs::read_dir(models_dir).unwrap().next().is_none(),
14258 "admission must happen before download/load"
14259 );
14260 }
14261
14262 #[tokio::test]
14263 async fn external_vllm_mlx_bypasses_local_admission_while_managed_artifact_does_not() {
14264 let schema = crate::vllm_mlx::to_model_schema(
14265 &crate::vllm_mlx::DiscoveredModel {
14266 id: "mlx-community/Qwen3-4B-4bit".into(),
14267 owned_by: None,
14268 },
14269 "http://localhost:8000",
14270 );
14271 assert!(!InferenceEngine::requires_local_admission(&schema));
14272
14273 let root = tempfile::tempdir().unwrap();
14274 let config = test_config(root.path().join("weights"));
14275 let repository =
14276 crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
14277 crate::resource_policy::ResourcePolicyRepository::save(
14278 &repository,
14279 &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
14280 )
14281 .unwrap();
14282 let engine = InferenceEngine::new(config);
14283 let (external, reservation) = engine
14284 .vllm_live_schema(schema.clone(), None, 0)
14285 .await
14286 .unwrap();
14287 assert!(matches!(
14288 external.source,
14289 ModelSource::VllmMlx { ref endpoint, .. } if endpoint == "http://localhost:8000"
14290 ));
14291 assert!(reservation.is_none());
14292
14293 let mut managed = schema;
14294 managed.source = ModelSource::ManagedVllmMlx {
14295 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
14296 hf_weight_file: None,
14297 };
14298 assert!(InferenceEngine::requires_local_admission(&managed));
14299 let error = engine
14300 .reserve_local_request(&managed, 0)
14301 .expect_err("zero-GB policy must reject before vllm runtime/download/spawn");
14302 assert!(matches!(
14303 error,
14304 InferenceError::LocalResourceBlocked {
14305 preflight: crate::resource_policy::LocalLoadPreflight {
14306 verdict: crate::resource_policy::LocalLoadVerdict::DisabledByPolicy,
14307 ..
14308 },
14309 ..
14310 }
14311 ));
14312 }
14313
14314 #[cfg(unix)]
14315 async fn assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(
14316 streaming: bool,
14317 cancel_first: bool,
14318 ) {
14319 struct FixedProbe;
14320 impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
14321 fn available_memory_mb(
14322 &self,
14323 ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
14324 Ok(Some(24_000))
14325 }
14326 }
14327
14328 let Some(python) = crate::vllm_runtime::test_python_interpreter() else {
14329 panic!("a real Python interpreter is required for the managed-vllm dispatch fixture");
14330 };
14331 let root = tempfile::tempdir().unwrap();
14332 let script = root.path().join("fake-vllm-mlx");
14333 let spawned = root.path().join("spawned");
14334 let release = root.path().join("release");
14335 std::fs::write(
14336 &script,
14337 format!(
14338 "#!{}\n\
14339 import http.server, os, sys, time\n\
14340 marker = {:?}\n\
14341 release = {:?}\n\
14342 open(marker, 'w').close()\n\
14343 while not os.path.exists(release): time.sleep(0.01)\n\
14344 port = int(sys.argv[sys.argv.index('--port') + 1])\n\
14345 class H(http.server.BaseHTTPRequestHandler):\n\
14346 \x20 def do_GET(self):\n\
14347 \x20 self.send_response(200); self.end_headers(); self.wfile.write(b'ok')\n\
14348 \x20 def do_POST(self):\n\
14349 \x20 length = int(self.headers.get('content-length', '0'))\n\
14350 \x20 request = self.rfile.read(length).replace(b' ', b'')\n\
14351 \x20 if b'\"stream\":true' in request:\n\
14352 \x20 body = b'data: {{\"id\":\"fixture\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"round13-stream\"}},\"finish_reason\":null}}]}}\\n\\ndata: {{\"id\":\"fixture\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\\n\\ndata: [DONE]\\n\\n'\n\
14353 \x20 content_type = 'text/event-stream'\n\
14354 \x20 else:\n\
14355 \x20 body = b'{{\"id\":\"fixture\",\"object\":\"chat.completion\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"message\":{{\"role\":\"assistant\",\"content\":\"round13-ok\"}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}}}'\n\
14356 \x20 content_type = 'application/json'\n\
14357 \x20 self.send_response(200); self.send_header('content-type', content_type); self.send_header('content-length', str(len(body))); self.end_headers(); self.wfile.write(body)\n\
14358 \x20 def log_message(self, *args): pass\n\
14359 http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()\n",
14360 python.display(),
14361 spawned.display().to_string(),
14362 release.display().to_string(),
14363 ),
14364 )
14365 .unwrap();
14366 use std::os::unix::fs::PermissionsExt;
14367 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
14368
14369 let models_dir = root.path().join("models");
14370 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
14371 let mut schema = crate::registry::builtin_catalog()
14372 .into_iter()
14373 .find(ModelSchema::is_car_managed_vllm_mlx)
14374 .expect("managed vllm fixture schema");
14375 schema.id = "vllm-mlx/round12-singleflight".into();
14376 schema.name = "round12-singleflight".into();
14377 schema.cost.size_mb = Some(1);
14378 schema.cost.ram_mb = Some(1);
14379 schema.source = ModelSource::ManagedVllmMlx {
14380 hf_repo: "fixture/round12-singleflight".into(),
14381 hf_weight_file: None,
14382 };
14383 let model_dir = models_dir.join(&schema.name);
14384 std::fs::create_dir_all(&model_dir).unwrap();
14385 std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
14386 std::fs::write(model_dir.join("model.safetensors"), b"fixture").unwrap();
14387 engine
14388 .unified_registry
14389 .register_project_model(schema.clone());
14390
14391 let coordinator = Arc::new(
14392 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
14393 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
14398 crate::hardware::HardwareInfo::detect(),
14399 Arc::new(FixedProbe),
14400 ),
14401 );
14402 engine.local_admission = coordinator.clone();
14403 engine.vllm_pool = Arc::new(crate::vllm_pool::VllmServerPool::with_test_runtime(
14404 std::time::Duration::from_secs(300),
14405 coordinator,
14406 script,
14407 ));
14408 let engine = Arc::new(engine);
14409 let model_id = schema.id.clone();
14410 let mut request = GenerateRequest {
14411 prompt: "hello from round 13".into(),
14412 model: Some(model_id.clone()),
14413 ..Default::default()
14414 };
14415 request.params.strict_model = true;
14420 let (estimated_input, _, _) = engine.estimated_tokens(&request, Some(&model_id));
14421 let expected_footprint = estimated_input.saturating_add(request.params.max_tokens);
14422 let expected_estimate = engine
14423 .local_model_preflight(&model_id, expected_footprint)
14424 .unwrap()
14425 .estimate;
14426 let expected_request_overhead_mb = expected_estimate
14427 .runtime_overhead_mb
14428 .saturating_add(expected_estimate.context_overhead_mb)
14429 .saturating_add(expected_estimate.transient_margin_mb);
14430
14431 async fn execute(
14432 engine: Arc<InferenceEngine>,
14433 request: GenerateRequest,
14434 streaming: bool,
14435 direct_vllm: bool,
14436 ) -> Result<String, InferenceError> {
14437 if direct_vllm {
14438 let model_id = request.model.as_deref().expect("explicit fixture model");
14439 let schema = engine
14440 .unified_registry
14441 .get(model_id)
14442 .cloned()
14443 .expect("fixture schema");
14444 let (schema, _) = engine.vllm_live_schema(schema, None, 0).await?;
14445 return match schema.source {
14446 ModelSource::VllmMlx { endpoint, .. } => Ok(endpoint),
14447 source => Err(InferenceError::InferenceFailed(format!(
14448 "fixture did not resolve to a vllm endpoint: {source:?}"
14449 ))),
14450 };
14451 }
14452 if !streaming {
14453 return engine
14454 .generate_tracked(request)
14455 .await
14456 .map(|result| result.text);
14457 }
14458 let mut tracked = engine.generate_tracked_stream(request).await?;
14459 let mut accumulator = crate::stream::StreamAccumulator::default();
14460 while let Some(event) = tracked.events.recv().await {
14461 let done = matches!(event, crate::stream::StreamEvent::Done { .. });
14462 accumulator.push(&event);
14463 if done {
14464 break;
14465 }
14466 }
14467 Ok(accumulator.finish().0)
14468 }
14469
14470 let first_engine = engine.clone();
14471 let first_request = request.clone();
14472 let mut first = tokio::spawn(async move {
14473 execute(first_engine, first_request, streaming, cancel_first).await
14474 });
14475 tokio::time::timeout(std::time::Duration::from_secs(5), async {
14476 while !spawned.exists() {
14477 if first.is_finished() {
14478 let result = (&mut first).await;
14479 panic!("first dispatch ended before spawn: {result:?}");
14480 }
14481 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
14482 }
14483 })
14484 .await
14485 .expect("first request must spawn before publication");
14486 if !cancel_first {
14487 let during_startup = engine.local_model_preflight(&model_id, 0).unwrap();
14488 assert_eq!(
14489 during_startup.active_reservations_mb, expected_request_overhead_mb,
14490 "the cold-to-pending transfer must retain all request context/KV overhead"
14491 );
14492 assert_eq!(
14493 during_startup.resident_model_mb, expected_estimate.weights_mb,
14494 "the pending allocation must replace the exact cold-weight charge"
14495 );
14496 assert_eq!(
14497 during_startup
14498 .active_reservations_mb
14499 .saturating_add(during_startup.resident_model_mb),
14500 expected_estimate.estimated_peak_mb,
14501 "active request overhead plus pending weights must preserve the full admitted footprint"
14502 );
14503 }
14504
14505 let second_engine = engine.clone();
14506 let mut second =
14507 tokio::spawn(
14508 async move { execute(second_engine, request, streaming, cancel_first).await },
14509 );
14510 assert!(
14511 tokio::time::timeout(std::time::Duration::from_millis(100), &mut second)
14512 .await
14513 .is_err(),
14514 "request 2 must wait for request 1 to publish instead of failing on startup state"
14515 );
14516
14517 let first_result = if cancel_first {
14518 std::fs::remove_file(&spawned).unwrap();
14519 first.abort();
14520 let _ = first.await;
14521 tokio::time::timeout(std::time::Duration::from_secs(5), async {
14522 while !spawned.exists() {
14523 if second.is_finished() {
14524 let result = (&mut second).await;
14525 panic!("waiter ended before replacement spawn: {result:?}");
14526 }
14527 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
14528 }
14529 })
14530 .await
14531 .expect("waiter must continue after cancelled owner teardown");
14532 None
14533 } else {
14534 std::fs::write(&release, b"release").unwrap();
14535 Some(
14536 tokio::time::timeout(std::time::Duration::from_secs(5), first)
14537 .await
14538 .expect("first dispatch completes")
14539 .unwrap()
14540 .unwrap(),
14541 )
14542 };
14543 std::fs::write(&release, b"release").unwrap();
14544 let second_result = tokio::time::timeout(std::time::Duration::from_secs(5), second)
14545 .await
14546 .expect("second dispatch completes")
14547 .unwrap()
14548 .unwrap();
14549 let expected = if cancel_first {
14550 None
14551 } else if streaming {
14552 Some("round13-stream")
14553 } else {
14554 Some("round13-ok")
14555 };
14556 if let (Some(first_result), Some(expected)) = (first_result, expected) {
14557 assert_eq!(first_result, expected);
14558 }
14559 if let Some(expected) = expected {
14560 assert_eq!(second_result, expected);
14561 } else {
14562 assert!(second_result.starts_with("http://127.0.0.1:"));
14563 }
14564 assert_eq!(engine.vllm_pool.len().await, 1);
14565 assert!(engine
14566 .vllm_pool
14567 .release_model_if_present(&model_id)
14568 .await
14569 .unwrap());
14570 }
14571
14572 #[cfg(unix)]
14573 #[tokio::test]
14574 async fn concurrent_managed_vllm_generate_waits_before_outer_reservation() {
14575 assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(false, false).await;
14576 }
14577
14578 #[cfg(unix)]
14579 #[tokio::test]
14580 async fn concurrent_managed_vllm_stream_waits_before_outer_reservation() {
14581 assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(true, false).await;
14582 }
14583
14584 #[cfg(unix)]
14585 #[tokio::test]
14586 async fn managed_vllm_waiter_continues_after_startup_owner_cancellation() {
14587 assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(false, true).await;
14588 }
14589
14590 #[tokio::test]
14591 async fn same_state_root_engines_share_runtime_components() {
14592 const CHILD_ENV: &str = "CAR_TWO_ENGINE_RUNTIME_TEST_CHILD";
14593 if std::env::var_os(CHILD_ENV).is_some() {
14594 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
14595 return;
14596 }
14597 let root = tempfile::tempdir().unwrap();
14598 let first = InferenceEngine::new(test_config(root.path().join("weights")));
14599 let second = InferenceEngine::new(test_config(root.path().join("weights")));
14600
14601 assert!(Arc::ptr_eq(&first.model_budget, &second.model_budget));
14602 assert!(Arc::ptr_eq(&first.vllm_pool, &second.vllm_pool));
14603 assert!(Arc::ptr_eq(
14604 &first.resource_policy_generation,
14605 &second.resource_policy_generation
14606 ));
14607 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
14608 {
14609 assert!(Arc::ptr_eq(&first.mlx_backends, &second.mlx_backends));
14610 assert!(Arc::ptr_eq(&first.local_backends, &second.local_backends));
14611 assert!(Arc::ptr_eq(&first.flux_cache, &second.flux_cache));
14612 assert!(Arc::ptr_eq(&first.ltx_cache, &second.ltx_cache));
14613 assert!(Arc::ptr_eq(&first.kokoro_cache, &second.kokoro_cache));
14614 }
14615 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
14616 {
14617 assert!(Arc::ptr_eq(&first.backend, &second.backend));
14618 assert!(Arc::ptr_eq(
14619 &first.embedding_backend,
14620 &second.embedding_backend
14621 ));
14622 }
14623
14624 let loads = Arc::new(std::sync::atomic::AtomicU64::new(0));
14625 let first_cache = first._runtime_scope.load_probe.clone();
14626 let second_cache = second._runtime_scope.load_probe.clone();
14627 let mut threads = Vec::new();
14628 for cache in [first_cache, second_cache] {
14629 let loads = loads.clone();
14630 threads.push(std::thread::spawn(move || {
14631 cache
14632 .get_or_load::<()>("same/model", 1, || {
14633 loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14634 std::thread::sleep(std::time::Duration::from_millis(20));
14635 Ok(())
14636 })
14637 .unwrap()
14638 }));
14639 }
14640 let handles = threads
14641 .into_iter()
14642 .map(|thread| thread.join().unwrap())
14643 .collect::<Vec<_>>();
14644 assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
14645 assert!(Arc::ptr_eq(&handles[0], &handles[1]));
14646
14647 let child = tokio::process::Command::new(std::env::current_exe().unwrap())
14648 .arg("--exact")
14649 .arg("tests::same_state_root_engines_share_runtime_components")
14650 .env(CHILD_ENV, "1")
14651 .stdin(std::process::Stdio::null())
14652 .stdout(std::process::Stdio::null())
14653 .stderr(std::process::Stdio::null())
14654 .kill_on_drop(true)
14655 .spawn()
14656 .unwrap();
14657 first
14658 .vllm_pool
14659 .insert_test_process("vllm-mlx/two-engine", child)
14660 .await;
14661 first.local_admission.mark_resident_allocation(
14662 "vllm-mlx/two-engine",
14663 &resource_policy::vllm_process_allocation_id("vllm-mlx/two-engine"),
14664 1,
14665 );
14666 assert!(second.vllm_pool.contains("vllm-mlx/two-engine").await);
14667 drop(first);
14668 assert!(second.vllm_pool.contains("vllm-mlx/two-engine").await);
14669 assert!(second.local_admission.is_resident("vllm-mlx/two-engine"));
14670 assert!(second.vllm_pool.evict_model("vllm-mlx/two-engine").await);
14671 assert!(!second.local_admission.is_resident("vllm-mlx/two-engine"));
14672 }
14673
14674 #[tokio::test]
14675 async fn last_engine_drop_reaps_vllm_before_new_runtime_admission() {
14676 const CHILD_ENV: &str = "CAR_LAST_ENGINE_VLLM_DROP_TEST_CHILD";
14677 if std::env::var_os(CHILD_ENV).is_some() {
14678 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
14679 return;
14680 }
14681 let root = tempfile::tempdir().unwrap();
14682 let config = test_config(root.path().join("weights"));
14683 let engine = InferenceEngine::new(config.clone());
14684 let coordinator = engine.local_admission.clone();
14685 let schema = engine
14686 .unified_registry
14687 .all()
14688 .find(|schema| schema.is_vllm_mlx())
14689 .cloned()
14690 .expect("supervised vllm schema");
14691 let child = tokio::process::Command::new(std::env::current_exe().unwrap())
14692 .arg("--exact")
14693 .arg("tests::last_engine_drop_reaps_vllm_before_new_runtime_admission")
14694 .env(CHILD_ENV, "1")
14695 .stdin(std::process::Stdio::null())
14696 .stdout(std::process::Stdio::null())
14697 .stderr(std::process::Stdio::null())
14698 .kill_on_drop(true)
14699 .spawn()
14700 .unwrap();
14701 engine
14702 .vllm_pool
14703 .insert_test_process(&schema.id, child)
14704 .await;
14705 coordinator.mark_resident_allocation(
14706 &schema.id,
14707 &resource_policy::vllm_process_allocation_id(&schema.id),
14708 1,
14709 );
14710
14711 drop(engine);
14712
14713 tokio::time::timeout(std::time::Duration::from_secs(2), async {
14714 while coordinator.teardown_pending(&schema.id) {
14715 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
14716 }
14717 })
14718 .await
14719 .expect("last runtime drop must eventually confirm supervised child exit");
14720 assert!(!coordinator.is_resident(&schema.id));
14721 let next = InferenceEngine::new(config);
14722 assert!(Arc::ptr_eq(&coordinator, &next.local_admission));
14723 let preflight = next.local_model_preflight(&schema.id, 0).unwrap();
14724 assert_eq!(preflight.resident_model_mb, 0);
14725 assert!(preflight.estimated_incremental_mb >= preflight.estimate.weights_mb);
14726 }
14727
14728 #[cfg(unix)]
14729 #[test]
14730 fn symlinked_state_roots_share_exact_coordinator_and_runtime() {
14731 use std::os::unix::fs::symlink;
14732
14733 let fixture = tempfile::tempdir().unwrap();
14734 let real = fixture.path().join("real-state");
14735 std::fs::create_dir(&real).unwrap();
14736 let alias = fixture.path().join("state-alias");
14737 symlink(&real, &alias).unwrap();
14738
14739 let first = InferenceEngine::new(test_config(real.join("weights")));
14740 let second = InferenceEngine::new(test_config(alias.join("weights")));
14741 assert!(Arc::ptr_eq(&first.local_admission, &second.local_admission));
14742 assert!(Arc::ptr_eq(&first._runtime_scope, &second._runtime_scope));
14743 assert!(Arc::ptr_eq(&first.model_budget, &second.model_budget));
14744 }
14745
14746 #[tokio::test]
14747 async fn stream_reservation_lives_until_returned_receiver_is_released() {
14748 let root = tempfile::tempdir().unwrap();
14749 let engine = InferenceEngine::new(test_config(root.path().join("weights")));
14750 let schema = crate::registry::builtin_catalog()
14751 .into_iter()
14752 .find(|schema| schema.is_local() && !schema.is_vllm_mlx())
14753 .expect("local model schema");
14754 engine.local_admission.mark_resident(&schema.id, 1);
14755 let reservation = engine.reserve_local_request(&schema, 64).unwrap();
14756 assert_eq!(engine.local_admission.active_request_count(&schema.id), 1);
14757 let (source_tx, source_rx) = tokio::sync::mpsc::channel(1);
14758 let returned =
14759 InferenceEngine::hold_optional_reservation_for_stream(source_rx, Some(reservation));
14760
14761 drop(returned);
14762 source_tx
14763 .send(stream::StreamEvent::TextDelta("release".into()))
14764 .await
14765 .unwrap();
14766 for _ in 0..20 {
14767 if engine.local_admission.active_request_count(&schema.id) == 0 {
14768 break;
14769 }
14770 tokio::task::yield_now().await;
14771 }
14772 assert_eq!(engine.local_admission.active_request_count(&schema.id), 0);
14773
14774 let compact_source = include_str!("lib.rs")
14775 .split_whitespace()
14776 .collect::<String>();
14777 let remote_handoff = [
14778 "Self::hold_optional_reservation_for_stream",
14779 "(receiver,",
14780 "candidate_reservation.take(),",
14781 ");",
14782 ]
14783 .concat();
14784 assert!(compact_source.contains(&remote_handoff));
14785 }
14786
14787 #[tokio::test]
14788 async fn residual_voice_allocation_blocks_removal_with_typed_error() {
14789 let root = tempfile::tempdir().unwrap();
14790 let engine = InferenceEngine::new(test_config(root.path().join("weights")));
14791 engine
14792 .local_admission
14793 .mark_resident("voice/removal-fixture", 1);
14794
14795 let error = match engine
14796 .prepare_local_model_removal("voice/removal-fixture")
14797 .await
14798 {
14799 Err(error) => error,
14800 Ok(_) => panic!("voice allocation must be released by its owner first"),
14801 };
14802 assert!(matches!(
14803 error,
14804 crate::resource_policy::ModelMaintenanceError::ResidualResidency {
14805 model_id,
14806 allocation_ids,
14807 } if model_id == "voice/removal-fixture"
14808 && allocation_ids == vec!["voice/removal-fixture"]
14809 ));
14810 }
14811
14812 #[tokio::test]
14813 async fn catalog_voice_id_blocks_removal_of_live_provider_alias() {
14814 let root = tempfile::tempdir().unwrap();
14815 let engine = InferenceEngine::new(test_config(root.path().join("weights")));
14816 engine.local_admission.register_model_aliases(
14817 "voice/parakeet-tdt-0.6b",
14818 ["mlx/parakeet-tdt-0.6b-v3:default"],
14819 );
14820 let mut reservation = engine
14821 .local_admission
14822 .reserve_measured_host_allocation(
14823 "voice/parakeet-tdt-0.6b",
14824 "voice/parakeet-tdt-0.6b#voice-allocation-0",
14825 1024 * 1024,
14826 0,
14827 )
14828 .unwrap();
14829 reservation.publish_resident_weights(1024 * 1024);
14830 drop(reservation);
14831
14832 let result = engine
14833 .prepare_local_model_removal("mlx/parakeet-tdt-0.6b-v3:default")
14834 .await;
14835 assert!(matches!(
14836 result,
14837 Err(crate::resource_policy::ModelMaintenanceError::ResidualResidency { .. })
14838 ));
14839 }
14840
14841 struct RefusingRemovalOffload;
14842
14843 #[async_trait::async_trait]
14844 impl crate::offload::LocalGenerationOffload for RefusingRemovalOffload {
14845 async fn generate(
14846 &self,
14847 _request: GenerateRequest,
14848 ) -> Result<InferenceResult, InferenceError> {
14849 unreachable!("removal test does not generate")
14850 }
14851
14852 async fn stream(
14853 &self,
14854 _request: GenerateRequest,
14855 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
14856 unreachable!("removal test does not stream")
14857 }
14858
14859 async fn resident_models(&self) -> Vec<String> {
14860 vec!["worker/refuses-release".into()]
14861 }
14862
14863 async fn release_model(&self, _model_id: &str) -> Result<bool, InferenceError> {
14864 Ok(false)
14865 }
14866 }
14867
14868 #[tokio::test]
14869 async fn worker_release_boolean_is_required_for_model_removal() {
14870 let _offload_guard = crate::offload::test_offload_lock().lock().await;
14871 crate::offload::set_local_offload(Some(Arc::new(RefusingRemovalOffload)));
14872 let root = tempfile::tempdir().unwrap();
14873 let engine = InferenceEngine::new(test_config(root.path().join("weights")));
14874 let result = engine
14875 .prepare_local_model_removal("worker/refuses-release")
14876 .await;
14877 crate::offload::set_local_offload(None);
14878
14879 assert!(matches!(
14880 result,
14881 Err(crate::resource_policy::ModelMaintenanceError::WorkerReleaseUnacknowledged(
14882 model_id
14883 )) if model_id == "worker/refuses-release"
14884 ));
14885 }
14886
14887 #[test]
14888 fn transient_mlx_vlm_allocation_never_becomes_resident() {
14889 struct FixedProbe;
14890 impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
14891 fn available_memory_mb(
14892 &self,
14893 ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
14894 Ok(Some(24_000))
14895 }
14896 }
14897
14898 let root = tempfile::tempdir().unwrap();
14899 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
14900 engine.local_admission = Arc::new(
14901 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
14902 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
14903 crate::hardware::HardwareInfo {
14904 total_ram_mb: 32 * 1024,
14905 ..crate::hardware::HardwareInfo::detect()
14906 },
14907 Arc::new(FixedProbe),
14908 ),
14909 );
14910 let mut schema = crate::registry::builtin_catalog()
14911 .into_iter()
14912 .find(|schema| schema.tags.iter().any(|tag| tag == "mlx-vlm-cli"))
14913 .expect("one-shot mlx-vlm schema");
14914 schema.param_count = "1M".into();
14915 schema.quantization = Some(Quantization::parse("Q4"));
14916 let mut reservation = engine.reserve_local_request(&schema, 64).unwrap();
14917 InferenceEngine::reconcile_transient_local_allocation(&mut reservation, 1024 * 1024)
14918 .unwrap();
14919 drop(reservation);
14920 assert!(!engine.local_admission.is_resident(&schema.id));
14921 }
14922
14923 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
14924 #[test]
14925 fn non_apple_backend_map_keeps_two_local_model_identities_exact() {
14926 let mut backends = std::collections::HashMap::<String, u32>::new();
14927 backends.insert("local/model-a".into(), 1);
14928 backends.insert("local/model-b".into(), 2);
14929 assert_eq!(backends.get("local/model-a"), Some(&1));
14930 assert_eq!(backends.get("local/model-b"), Some(&2));
14931 assert_eq!(backends.remove("local/model-a"), Some(1));
14932 assert!(!backends.contains_key("local/model-a"));
14933 assert_eq!(backends.get("local/model-b"), Some(&2));
14934 }
14935
14936 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
14937 #[tokio::test]
14938 async fn non_apple_remote_classification_at_zero_gb_routes_without_local_allocation() {
14939 use wiremock::matchers::{method, path};
14940 use wiremock::{Mock, MockServer, ResponseTemplate};
14941
14942 let _credential_scope = crate::openrouter::test_credential_scope();
14943 crate::openrouter::set_test_credential(Some("test-openrouter-key"));
14944 let server = MockServer::start().await;
14945 Mock::given(method("POST"))
14946 .and(path("/v1/chat/completions"))
14947 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
14948 "choices": [{
14949 "message": {"role": "assistant", "content": "positive"},
14950 "finish_reason": "stop"
14951 }],
14952 "usage": {"prompt_tokens": 16, "completion_tokens": 1}
14953 })))
14954 .mount(&server)
14955 .await;
14956
14957 let fixture = tempfile::tempdir().unwrap();
14958 let config = test_config(fixture.path().join("weights"));
14959 let repository =
14960 crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
14961 crate::resource_policy::ResourcePolicyRepository::save(
14962 &repository,
14963 &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
14964 )
14965 .unwrap();
14966 let mut engine = InferenceEngine::new(config);
14967 let mut remote = crate::openrouter::curated_schemas()
14968 .into_iter()
14969 .next()
14970 .expect("OpenRouter schema");
14971 remote.id = "openrouter/test/classifier".into();
14972 remote.name = remote.id.clone();
14973 if let ModelSource::RemoteApi { endpoint, .. } = &mut remote.source {
14974 *endpoint = server.uri();
14975 }
14976 let remote_id = remote.id.clone();
14977 engine.unified_registry.register_project_model(remote);
14978
14979 let result = engine
14980 .classify(ClassifyRequest {
14981 text: "a good outcome".into(),
14982 labels: vec!["positive".into(), "negative".into()],
14983 model: Some(remote_id),
14984 })
14985 .await
14986 .unwrap();
14987 assert_eq!(
14988 result.first().map(|item| item.label.as_str()),
14989 Some("positive")
14990 );
14991 assert_eq!(engine.local_admission.resident_model_mb(), 0);
14992 assert!(
14993 !engine.config.models_dir.exists()
14994 || std::fs::read_dir(&engine.config.models_dir)
14995 .unwrap()
14996 .next()
14997 .is_none(),
14998 "remote classification must not download local weights"
14999 );
15000 }
15001
15002 #[test]
15003 fn adaptive_speech_skips_blocked_local_but_explicit_speech_fails() {
15004 let fixture = TempDir::new().unwrap();
15005 let config = InferenceConfig {
15006 models_dir: fixture.path().join("weights"),
15007 state_root: fixture.path().join("state"),
15008 ..Default::default()
15009 };
15010 let repository =
15011 crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
15012 crate::resource_policy::ResourcePolicyRepository::save(
15013 &repository,
15014 &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
15015 )
15016 .unwrap();
15017 let engine = InferenceEngine::new(config);
15018 let local = engine
15019 .unified_registry
15020 .all()
15021 .find(|schema| {
15022 schema.is_local() && schema.has_capability(ModelCapability::SpeechToText)
15023 })
15024 .cloned()
15025 .expect("built-in local STT model");
15026
15027 assert!(matches!(
15028 engine.admit_speech_candidate(&local, false),
15029 SpeechCandidateAdmission::SkipBlocked(_)
15030 ));
15031 assert!(matches!(
15032 engine.admit_speech_candidate(&local, true),
15033 SpeechCandidateAdmission::FailBlocked(InferenceError::LocalResourceBlocked { .. })
15034 ));
15035
15036 let mut os_owned = local;
15037 os_owned.id = "windows/speech-synthesis:test".into();
15038 os_owned.source = ModelSource::WindowsSpeech {};
15039 assert!(matches!(
15040 engine.admit_speech_candidate(&os_owned, true),
15041 SpeechCandidateAdmission::Proceed(None)
15042 ));
15043 }
15044
15045 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15046 async fn cancelled_native_blocking_work_keeps_its_exact_charge_until_completion() {
15047 let coordinator = std::sync::Arc::new(resource_policy::LocalAdmissionCoordinator::new(
15048 resource_policy::ResourcePolicy::custom_gb(1.0).unwrap(),
15049 crate::hardware::HardwareInfo {
15050 os: "test".into(),
15051 arch: "test".into(),
15052 cpu_cores: 8,
15053 total_ram_mb: 32 * 1024,
15054 gpu_backend: crate::hardware::GpuBackend::Cpu,
15055 gpu_memory_mb: None,
15056 gpu_devices: Vec::new(),
15057 recommended_model: "fixture".into(),
15058 recommended_context: 4096,
15059 max_model_mb: 32 * 1024,
15060 },
15061 ));
15062 let reservation = coordinator
15063 .reserve_measured_host("detached-native-a", 512 * 1024 * 1024, 0)
15064 .unwrap();
15065 let lease = reservation.detached_lease();
15066 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
15067 let release =
15068 std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
15069 let release_worker = release.clone();
15070 let operation = tokio::spawn(async move {
15071 let _request_reservation = reservation;
15072 run_admitted_blocking(lease, move || {
15073 let _ = started_tx.send(());
15074 let (lock, ready) = &*release_worker;
15075 let mut released = lock
15076 .lock()
15077 .unwrap_or_else(std::sync::PoisonError::into_inner);
15078 while !*released {
15079 released = ready
15080 .wait(released)
15081 .unwrap_or_else(std::sync::PoisonError::into_inner);
15082 }
15083 })
15084 .await
15085 });
15086 started_rx.await.unwrap();
15087 operation.abort();
15088 let _ = operation.await;
15089
15090 let blocked = coordinator.reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0);
15091 assert!(
15092 blocked.is_err(),
15093 "cancelling the await must not advertise memory still owned by spawn_blocking"
15094 );
15095
15096 let (lock, ready) = &*release;
15097 *lock
15098 .lock()
15099 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
15100 ready.notify_one();
15101 tokio::time::timeout(std::time::Duration::from_secs(2), async {
15102 loop {
15103 if coordinator
15104 .reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0)
15105 .is_ok()
15106 {
15107 break;
15108 }
15109 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
15110 }
15111 })
15112 .await
15113 .expect("the detached charge must clear after native work really exits");
15114 }
15115
15116 #[test]
15117 fn nonstream_native_text_generation_uses_cancellable_blocking_boundary() {
15118 let source = include_str!("lib.rs");
15119 let mlx = source
15120 .split("async fn generate_mlx(")
15121 .nth(1)
15122 .and_then(|tail| tail.split("async fn generate_local(").next())
15123 .expect("generate_mlx source");
15124 let local = source
15125 .split("async fn generate_local(")
15126 .nth(1)
15127 .and_then(|tail| tail.split("fn apply_top_k_top_p").next())
15128 .expect("generate_local source");
15129
15130 assert!(
15131 mlx.contains("run_admitted_blocking"),
15132 "native MLX decode must yield the Tokio runtime so a WS timeout/cancel can preempt its response waiter"
15133 );
15134 assert!(
15135 local.contains("run_admitted_blocking"),
15136 "polymorphic local decode must yield the Tokio runtime so a WS timeout/cancel can preempt its response waiter"
15137 );
15138 }
15139
15140 #[cfg(unix)]
15141 #[tokio::test]
15142 async fn cancelled_speech_subprocess_is_killed_and_reaped_before_charge_clears() {
15143 use std::os::unix::fs::PermissionsExt;
15144
15145 let fixture = TempDir::new().unwrap();
15146 let runtime = SpeechRuntime::new(fixture.path().join("speech-runtime"));
15147 std::fs::create_dir_all(runtime.stt_program.parent().unwrap()).unwrap();
15148 std::fs::write(
15149 &runtime.stt_program,
15150 b"#!/bin/sh\necho started > \"$1\"\nsleep 1\necho continued > \"$2\"\n",
15151 )
15152 .unwrap();
15153 let mut permissions = std::fs::metadata(&runtime.stt_program)
15154 .unwrap()
15155 .permissions();
15156 permissions.set_mode(0o755);
15157 std::fs::set_permissions(&runtime.stt_program, permissions).unwrap();
15158
15159 let coordinator = std::sync::Arc::new(resource_policy::LocalAdmissionCoordinator::new(
15160 resource_policy::ResourcePolicy::custom_gb(1.0).unwrap(),
15161 crate::hardware::HardwareInfo {
15162 total_ram_mb: 32 * 1024,
15163 ..crate::hardware::HardwareInfo::detect()
15164 },
15165 ));
15166 let reservation = coordinator
15167 .reserve_measured_host("mlx-audio-a", 512 * 1024 * 1024, 0)
15168 .unwrap();
15169 let lease = reservation.detached_lease();
15170 let started = fixture.path().join("started");
15171 let continued = fixture.path().join("continued");
15172 let args = vec![
15173 started.display().to_string(),
15174 continued.display().to_string(),
15175 ];
15176 let command = tokio::spawn(async move {
15177 let _request_reservation = reservation;
15178 run_mlx_audio_command(&runtime, "stt.generate", &args, lease).await
15179 });
15180 tokio::time::timeout(std::time::Duration::from_secs(2), async {
15181 while !started.exists() {
15182 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
15183 }
15184 })
15185 .await
15186 .expect("speech fixture child must start");
15187 command.abort();
15188 let _ = command.await;
15189
15190 tokio::time::timeout(std::time::Duration::from_secs(2), async {
15191 loop {
15192 if coordinator
15193 .reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0)
15194 .is_ok()
15195 {
15196 break;
15197 }
15198 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
15199 }
15200 })
15201 .await
15202 .expect("charge clears only after the cancelled child is reaped");
15203 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
15204 assert!(
15205 !continued.exists(),
15206 "the cancelled local speech process must not keep allocating in the background"
15207 );
15208 }
15209
15210 #[test]
15211 fn local_model_eviction_surface_includes_every_in_process_cache() {
15212 let source = include_str!("lib.rs");
15213 for cache in [
15214 "self.mlx_backends.evict_if_idle(model_id)",
15215 "self.local_backends.evict_if_idle(model_id)",
15216 "self.flux_cache.evict_if_idle(model_id)",
15217 "self.ltx_cache.evict_if_idle(model_id)",
15218 "self.kokoro_cache.evict_if_idle(model_id)",
15219 "self.local_backends.evict_idle()",
15220 ] {
15221 assert!(source.contains(cache), "missing eviction seam: {cache}");
15222 }
15223 }
15224
15225 #[tokio::test]
15231 async fn catalog_refresh_uses_authority_hints_without_secret_reads() {
15232 if !crate::run_in_isolated_test_process(
15233 "tests::catalog_refresh_uses_authority_hints_without_secret_reads",
15234 "CAR_CATALOG_ZERO_SECRET_READ_CHILD",
15235 ) {
15236 return;
15237 }
15238 let _environment = ENV_MUTEX.lock().await;
15239 let fixture = TempDir::new().unwrap();
15240 let credential_envs = [
15241 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
15242 car_auth::PARSLEE_API_BASE_KEY,
15243 crate::openrouter::API_KEY_ENV,
15244 "OPENAI_API_KEY",
15245 "ANTHROPIC_API_KEY",
15246 "GOOGLE_API_KEY",
15247 "ELEVENLABS_API_KEY",
15248 ];
15249 let mut restored_names = vec![car_home::ENV_VAR, "CAR_SECRETS_FILE_DIR"];
15250 restored_names.extend(credential_envs);
15251 let _restore = RestoredEnvironment::capture(&restored_names);
15252 unsafe {
15253 std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
15254 std::env::set_var(
15255 "CAR_SECRETS_FILE_DIR",
15256 fixture.path().join("isolated-secrets"),
15257 );
15258 for name in credential_envs {
15259 std::env::remove_var(name);
15260 }
15261 }
15262
15263 let before = car_secrets::secret_store_activity();
15264 let engine = InferenceEngine::new(test_config(fixture.path().join("models")));
15265
15266 let listed = engine.list_models_unified();
15268 let schemas = engine.list_schemas();
15269 for provider in ["parslee", "openai", "anthropic", "google", "elevenlabs"] {
15270 assert!(
15271 schemas.iter().any(|schema| schema.provider == provider),
15272 "real builtin catalog lost the credential-bearing {provider} rows"
15273 );
15274 }
15275 assert!(
15276 schemas.iter().any(|schema| matches!(
15277 schema.source,
15278 ModelSource::RemoteApi {
15279 protocol: ApiProtocol::OpenRouter,
15280 ..
15281 }
15282 )),
15283 "real builtin catalog lost the reviewed OpenRouter rows"
15284 );
15285 assert_eq!(listed.len(), schemas.len());
15286
15287 let schema_refs: Vec<&ModelSchema> = schemas.iter().collect();
15289 let _setup_plan = crate::recommend(
15290 &schema_refs,
15291 &HardwareInfo::detect(),
15292 UseCase::default(),
15293 QualityTier::default(),
15294 Privacy::OnDevice,
15295 );
15296
15297 let _speech = engine.speech_health();
15299 let _concierge = engine.concierge_status(false).await;
15300 let _health = engine.model_health().await;
15301
15302 let after = car_secrets::secret_store_activity();
15303 assert_eq!(
15304 after, before,
15305 "passive builtin catalog surfaces performed secret-store operations"
15306 );
15307 }
15308
15309 #[tokio::test]
15310 async fn list_models_unified_and_model_health_are_zero_secret_store_probes() {
15311 if !crate::run_in_isolated_test_process(
15312 "tests::list_models_unified_and_model_health_are_zero_secret_store_probes",
15313 "CAR_DIRECT_MODEL_SURFACES_ZERO_SECRET_CHILD",
15314 ) {
15315 return;
15316 }
15317 let _environment = ENV_MUTEX.lock().await;
15318 let fixture = TempDir::new().unwrap();
15319 let _restore = RestoredEnvironment::capture(&[
15320 car_home::ENV_VAR,
15321 "CAR_SECRETS_FILE_DIR",
15322 crate::openrouter::API_KEY_ENV,
15323 ]);
15324 unsafe {
15325 std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
15326 std::env::set_var(
15327 "CAR_SECRETS_FILE_DIR",
15328 fixture.path().join("isolated-secrets"),
15329 );
15330 std::env::remove_var(crate::openrouter::API_KEY_ENV);
15331 }
15332 let engine = InferenceEngine::new(test_config(fixture.path().join("models")));
15333 let before = car_secrets::secret_store_activity();
15334
15335 let rows = engine.list_models_unified();
15336 let health = engine.model_health().await;
15337
15338 assert!(!rows.is_empty(), "the unified catalog fixture must exist");
15339 assert!(health.total_models > 0, "the health fixture must exist");
15340 let after = car_secrets::secret_store_activity();
15341 assert_eq!(after.status_attempts, before.status_attempts);
15342 assert_eq!(after.get_attempts, before.get_attempts);
15343 }
15344
15345 #[test]
15355 fn car_home_moves_the_catalog_and_discovery_caches_but_never_the_weights() {
15356 let _environment = crate::openrouter::test_environment_scope();
15357 let prior = std::env::var_os(car_home::ENV_VAR);
15358
15359 unsafe { std::env::remove_var(car_home::ENV_VAR) };
15360 let shared = InferenceConfig::default();
15361 let default_catalog = crate::catalog::cache_path(&shared.state_root);
15362 let default_discovery = crate::discovery::cache_path(&shared.state_models_dir());
15363
15364 let alt = Path::new("/tmp/car-home-inference-cache-test");
15365 unsafe { std::env::set_var(car_home::ENV_VAR, alt) };
15366 let relocated = InferenceConfig::default();
15367 let catalog = crate::catalog::cache_path(&relocated.state_root);
15368 let discovery = crate::discovery::cache_path(&relocated.state_models_dir());
15369
15370 match prior {
15371 Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
15372 None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
15373 }
15374
15375 assert_eq!(catalog, alt.join(crate::catalog::CATALOG_CACHE_FILE));
15376 assert_eq!(
15377 discovery,
15378 alt.join("models")
15379 .join(crate::discovery::DISCOVERED_MODELS_FILE)
15380 );
15381 assert_ne!(
15382 catalog, default_catalog,
15383 "the catalog cache must not resolve back into the shared root",
15384 );
15385 assert_ne!(
15386 discovery, default_discovery,
15387 "the discovery cache must not resolve back into the shared root",
15388 );
15389
15390 assert_eq!(
15391 relocated.models_dir, shared.models_dir,
15392 "the weights cache is machine-global and must not follow CAR_HOME",
15393 );
15394 assert!(
15395 !relocated.models_dir.starts_with(alt),
15396 "the weights cache must not be dragged under the override",
15397 );
15398 }
15399
15400 #[derive(Clone, Copy)]
15401 enum CacheRoutingSurface {
15402 Generate,
15403 Stream,
15404 }
15405
15406 async fn invoke_cache_routed_openrouter(
15412 surface: CacheRoutingSurface,
15413 cache_read_estimate: usize,
15414 cache_write_estimate: usize,
15415 ) -> String {
15416 use wiremock::matchers::{method, path};
15417 use wiremock::{Mock, MockServer, ResponseTemplate};
15418
15419 let _credential_scope = crate::openrouter::test_credential_scope();
15420 crate::openrouter::set_test_credential(Some("test-openrouter-key"));
15421 let server = MockServer::start().await;
15422 let response = match surface {
15423 CacheRoutingSurface::Generate => ResponseTemplate::new(200).set_body_json(
15424 serde_json::json!({
15425 "choices": [{
15426 "message": {"role": "assistant", "content": "cache-route-ok"},
15427 "finish_reason": "stop"
15428 }],
15429 "usage": {"prompt_tokens": 40_000, "completion_tokens": 8}
15430 }),
15431 ),
15432 CacheRoutingSurface::Stream => ResponseTemplate::new(200).set_body_raw(
15433 concat!(
15434 "data: {\"choices\":[{\"delta\":{\"content\":\"cache-route-ok\"},\"finish_reason\":\"stop\"}]}\n\n",
15435 "data: [DONE]\n\n"
15436 ),
15437 "text/event-stream",
15438 ),
15439 };
15440 Mock::given(method("POST"))
15441 .and(path("/v1/chat/completions"))
15442 .respond_with(response)
15443 .mount(&server)
15444 .await;
15445
15446 let tmp = TempDir::new().unwrap();
15447 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
15448 let mut uncached_cheap = crate::openrouter::curated_schemas()
15449 .into_iter()
15450 .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
15451 .unwrap();
15452 uncached_cheap.id = "openrouter/test/uncached-cheap".into();
15453 uncached_cheap.name = uncached_cheap.id.clone();
15454 uncached_cheap.cost = CostModel {
15455 input_per_mtok: Some(1.0),
15456 output_per_mtok: Some(1.0),
15457 cache_read_input_per_mtok: Some(100.0),
15458 cache_write_input_per_mtok: Some(100.0),
15459 ..Default::default()
15460 };
15461 if let ModelSource::RemoteApi { endpoint, .. } = &mut uncached_cheap.source {
15462 *endpoint = server.uri();
15463 }
15464
15465 let mut cached_cheap = uncached_cheap.clone();
15466 cached_cheap.id = "openrouter/test/cached-cheap".into();
15467 cached_cheap.name = cached_cheap.id.clone();
15468 cached_cheap.cost = CostModel {
15469 input_per_mtok: Some(80.0),
15470 output_per_mtok: Some(1.0),
15471 cache_read_input_per_mtok: Some(0.001),
15472 cache_write_input_per_mtok: Some(0.001),
15473 ..Default::default()
15474 };
15475
15476 let uncached_id = uncached_cheap.id.clone();
15477 let cached_id = cached_cheap.id.clone();
15478 engine
15479 .unified_registry
15480 .register_project_model(uncached_cheap);
15481 engine.unified_registry.register_project_model(cached_cheap);
15482 let exclude_models = engine
15483 .list_schemas()
15484 .into_iter()
15485 .map(|schema| schema.id)
15486 .filter(|id| id != &uncached_id && id != &cached_id)
15487 .collect();
15488
15489 let mut params = GenerateParams {
15493 max_tokens: 8,
15494 ..Default::default()
15495 };
15496 assert_eq!(params.estimated_cache_read_input_tokens, 0);
15497 assert_eq!(params.estimated_cache_write_input_tokens, 0);
15498 params.estimated_cache_read_input_tokens = cache_read_estimate;
15499 params.estimated_cache_write_input_tokens = cache_write_estimate;
15500 let req = GenerateRequest {
15501 prompt: "x".repeat(160_000),
15502 params,
15503 cache_control: true,
15504 intent: Some(IntentHint {
15505 prefer_quality: true,
15506 exclude_models,
15507 ..Default::default()
15508 }),
15509 ..Default::default()
15510 };
15511
15512 match surface {
15513 CacheRoutingSurface::Generate => {
15514 engine
15515 .generate_tracked(req)
15516 .await
15517 .expect("mocked OpenRouter generation should succeed")
15518 .model_used
15519 }
15520 CacheRoutingSurface::Stream => {
15521 let mut handle = engine
15522 .generate_tracked_stream(req)
15523 .await
15524 .expect("mocked OpenRouter stream should start");
15525 let selected = handle.model_used.clone();
15526 while handle.events.recv().await.is_some() {}
15527 selected
15528 }
15529 }
15530 }
15531
15532 #[tokio::test(flavor = "current_thread")]
15533 async fn tracked_generate_uses_explicit_cache_estimate_and_defaults_to_zero() {
15534 let without_estimate =
15535 invoke_cache_routed_openrouter(CacheRoutingSurface::Generate, 0, 0).await;
15536 let with_estimate =
15537 invoke_cache_routed_openrouter(CacheRoutingSurface::Generate, 40_000, 0).await;
15538 assert_eq!(without_estimate, "openrouter/test/uncached-cheap");
15539 assert_eq!(with_estimate, "openrouter/test/cached-cheap");
15540 }
15541
15542 #[tokio::test(flavor = "current_thread")]
15543 async fn tracked_stream_uses_explicit_cache_estimate_and_defaults_to_zero() {
15544 let without_estimate =
15545 invoke_cache_routed_openrouter(CacheRoutingSurface::Stream, 0, 0).await;
15546 let with_estimate =
15547 invoke_cache_routed_openrouter(CacheRoutingSurface::Stream, 0, 40_000).await;
15548 assert_eq!(without_estimate, "openrouter/test/uncached-cheap");
15549 assert_eq!(with_estimate, "openrouter/test/cached-cheap");
15550 }
15551
15552 #[tokio::test(flavor = "current_thread")]
15553 async fn authenticated_openrouter_registry_stays_static_and_rejects_unknown_ids() {
15554 let _credential_scope = crate::openrouter::test_credential_scope();
15555 crate::openrouter::set_test_credential(Some("static-key"));
15556 let tmp = TempDir::new().unwrap();
15557 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
15558 let personal: Vec<_> = engine
15559 .list_schemas()
15560 .into_iter()
15561 .filter(|schema| schema.id.starts_with("openrouter/"))
15562 .collect();
15563 assert_eq!(personal.len(), crate::openrouter::curated_model_count());
15564 assert!(personal.iter().all(ModelSchema::available_now));
15565 assert!(personal
15566 .iter()
15567 .all(|schema| schema.trust_tier == TrustTier::Curated));
15568 assert!(personal
15569 .iter()
15570 .all(|schema| !schema.tags.iter().any(|tag| tag == "dynamic")));
15571
15572 for unknown in [
15573 "openrouter/vendor/brand-new-model",
15574 "openrouter/openai/gpt-5.4-typo",
15575 ] {
15576 assert!(engine
15577 .list_schemas()
15578 .iter()
15579 .all(|schema| schema.id != unknown));
15580 assert_eq!(engine.model_context_window(unknown), 0);
15581 let error = engine
15582 .generate_tracked(GenerateRequest {
15583 prompt: "must fail before transport".into(),
15584 model: Some(unknown.into()),
15585 params: GenerateParams {
15586 strict_model: true,
15587 ..Default::default()
15588 },
15589 ..Default::default()
15590 })
15591 .await
15592 .expect_err("unregistered personal OpenRouter ids must not reach inference");
15593 assert!(
15594 matches!(&error, InferenceError::ModelNotFound(id) if id == unknown),
15595 "{unknown}: {error}"
15596 );
15597 let stream_error = match engine
15598 .generate_tracked_stream(GenerateRequest {
15599 prompt: "must fail before stream transport".into(),
15600 model: Some(unknown.into()),
15601 ..Default::default()
15602 })
15603 .await
15604 {
15605 Ok(_) => panic!("unregistered ids must also fail before streaming"),
15606 Err(error) => error,
15607 };
15608 assert!(
15609 matches!(&stream_error, InferenceError::ModelNotFound(id) if id == unknown),
15610 "{unknown}: {stream_error}"
15611 );
15612 }
15613 }
15614
15615 #[tokio::test(flavor = "current_thread")]
15616 async fn static_openrouter_rows_participate_in_adaptive_routing_only_with_a_key() {
15617 let _credential_scope = crate::openrouter::test_credential_scope();
15618 let _provider_env = crate::openrouter::test_environment_scope_async().await;
15619 let tmp = TempDir::new().unwrap();
15620 crate::openrouter::set_test_credential(Some("static-key"));
15621 unsafe {
15622 std::env::set_var("CAR_STATIC_ROUTING_PEER_KEY", "peer-key");
15623 }
15624 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
15625 let mut peer = remote_stream_fixture_schema(
15626 "test/openai-routing-peer",
15627 "http://127.0.0.1:9".into(),
15628 schema::ApiProtocol::OpenAiCompat,
15629 "CAR_STATIC_ROUTING_PEER_KEY",
15630 );
15631 peer.provider = "openai".into();
15632 peer.trust_tier = TrustTier::Curated;
15633 engine.unified_registry.register_project_model(peer);
15634 let reviewed: std::collections::HashSet<_> = engine
15635 .list_schemas()
15636 .into_iter()
15637 .filter(|schema| schema.id.starts_with("openrouter/"))
15638 .map(|schema| schema.id)
15639 .collect();
15640 assert_eq!(reviewed.len(), crate::openrouter::curated_model_count());
15641
15642 let with_key = engine
15643 .route_adaptive_with_intent(
15644 "Answer this simple question cheaply.",
15645 Some(IntentHint::default()),
15646 )
15647 .await;
15648 let openrouter_candidates: Vec<_> = std::iter::once(with_key.model_id.as_str())
15649 .chain(
15650 with_key
15651 .candidates
15652 .iter()
15653 .map(|candidate| candidate.model_id.as_str()),
15654 )
15655 .chain(with_key.fallbacks.iter().map(String::as_str))
15656 .filter(|id| id.starts_with("openrouter/"))
15657 .collect();
15658 assert!(
15659 !openrouter_candidates.is_empty(),
15660 "keyed adaptive decision must include a reviewed OpenRouter row: {with_key:?}"
15661 );
15662 assert!(openrouter_candidates
15663 .iter()
15664 .all(|id| reviewed.contains(*id)));
15665 assert!(
15666 std::iter::once(with_key.model_id.as_str())
15667 .chain(with_key.fallbacks.iter().map(String::as_str),)
15668 .any(|id| !id.starts_with("openrouter/")),
15669 "fallback chain must retain cross-provider alternatives: {with_key:?}"
15670 );
15671
15672 crate::openrouter::set_test_credential(None);
15673 let without_key = engine
15674 .route_adaptive_with_intent(
15675 "Answer this simple question cheaply.",
15676 Some(IntentHint::default()),
15677 )
15678 .await;
15679 assert!(!std::iter::once(without_key.model_id.as_str())
15680 .chain(
15681 without_key
15682 .candidates
15683 .iter()
15684 .map(|candidate| candidate.model_id.as_str()),
15685 )
15686 .chain(without_key.fallbacks.iter().map(String::as_str))
15687 .any(|id| id.starts_with("openrouter/")));
15688 unsafe {
15689 std::env::remove_var("CAR_STATIC_ROUTING_PEER_KEY");
15690 }
15691 }
15692
15693 #[tokio::test(flavor = "current_thread")]
15694 async fn v2_parslee_auth_drives_managed_registration_routing_lane_and_logout() {
15695 if !crate::run_in_isolated_test_process(
15696 "tests::v2_parslee_auth_drives_managed_registration_routing_lane_and_logout",
15697 "CAR_V2_ROUTING_AUTH_CHILD",
15698 ) {
15699 return;
15700 }
15701 let tmp = TempDir::new().unwrap();
15702 let _credential_scope = crate::openrouter::test_credential_scope();
15703 let _provider_env = crate::openrouter::test_environment_scope_async().await;
15704 let _restore = RestoredEnvironment::capture(&[
15705 "CAR_SECRETS_FILE_DIR",
15706 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
15707 ]);
15708 let _home = crate::openrouter::StateRootScope::new();
15715 crate::openrouter::clear_gateway_unconfigured();
15716 crate::parslee_credential::clear_credential_rejected();
15717 let secrets_dir = tmp.path().join("secrets");
15718 unsafe {
15719 std::env::set_var("CAR_SECRETS_FILE_DIR", &secrets_dir);
15720 std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
15721 }
15722 crate::openrouter::set_test_credential(Some("personal-openrouter-key"));
15723
15724 let store = car_secrets::SecretStore::new();
15725 let state_ref =
15726 car_secrets::SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
15727 store
15728 .publish(
15729 &state_ref,
15730 &serde_json::json!({
15731 "schema": 2,
15732 "revision": 7,
15733 "generation": 3,
15734 "active": {
15735 "account_id": "account-v2",
15736 "access_token": "v2-access",
15737 "refresh_token": "v2-refresh",
15738 "expires_at": 9_999_999_999_u64,
15739 "api_base": "https://api.parslee.ai"
15740 },
15741 "accounts": [{
15742 "account_id": "account-v2",
15743 "access_token": "v2-access",
15744 "refresh_token": "v2-refresh",
15745 "expires_at": 9_999_999_999_u64,
15746 "api_base": "https://api.parslee.ai"
15747 }],
15748 "tombstone": false
15749 })
15750 .to_string(),
15751 )
15752 .unwrap();
15753 assert_eq!(car_auth::access_token().as_deref(), Some("v2-access"));
15754 assert!(
15755 !store
15756 .status(&car_secrets::SecretRef::with_default_service(
15757 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
15758 ))
15759 .unwrap()
15760 .exists,
15761 "the regression must exercise V2 state without the import-only legacy slot"
15762 );
15763
15764 let managed_ids = [
15765 "parslee/openrouter/frontier-general",
15766 "parslee/openrouter/balanced-general",
15767 ];
15768 let personal_id = "openrouter/deepseek/deepseek-v3.2";
15769 let rogue_id = "community/custom-oauth";
15770 let schemas = crate::openrouter::curated_schemas();
15771 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("registry-models"));
15772 for id in managed_ids.into_iter().chain(std::iter::once(personal_id)) {
15773 registry.register_project_model(
15774 schemas
15775 .iter()
15776 .find(|schema| schema.id == id)
15777 .unwrap_or_else(|| panic!("missing curated schema {id}"))
15778 .clone(),
15779 );
15780 }
15781 registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
15782 for id in managed_ids {
15783 assert!(
15784 registry.get(id).unwrap().available_now(),
15785 "{id} must be available immediately when registered from V2 auth"
15786 );
15787 }
15788 assert!(registry.get(personal_id).unwrap().available_now());
15789 let mut rogue = schemas
15790 .iter()
15791 .find(|schema| schema.id == managed_ids[0])
15792 .unwrap()
15793 .clone();
15794 rogue.id = rogue_id.into();
15795 rogue.provider = "community".into();
15796 if let ModelSource::Proprietary {
15797 provider, endpoint, ..
15798 } = &mut rogue.source
15799 {
15800 *provider = "community".into();
15801 *endpoint = "https://untrusted.example".into();
15802 } else {
15803 panic!("managed fixture must remain proprietary");
15804 }
15805 registry.register(rogue);
15806 assert!(
15807 !registry.get(rogue_id).unwrap().available_now(),
15808 "a non-Parslee OAuth schema must not inherit Parslee V2 availability"
15809 );
15810
15811 registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
15812 for id in managed_ids {
15813 assert!(
15814 registry.get(id).unwrap().available_now(),
15815 "{id} must stay available in a refreshed V2-auth snapshot"
15816 );
15817 }
15818 assert!(
15819 !registry.get(rogue_id).unwrap().available_now(),
15820 "refresh must keep non-Parslee OAuth schemas unavailable"
15821 );
15822
15823 let router = AdaptiveRouter::new(
15824 crate::hardware::HardwareInfo::detect(),
15825 RoutingConfig {
15826 prefer_local: false,
15827 prior_strength: 1_000_000.0,
15828 quality_first_cold_start: false,
15829 ..RoutingConfig::default()
15830 },
15831 );
15832 let tracker = OutcomeTracker::new();
15833 let intent = IntentHint {
15834 task: Some(crate::intent::TaskHint::Chat),
15835 exclude_models: vec![personal_id.into()],
15836 ..Default::default()
15837 };
15838 let decision = router.route_with(crate::adaptive_router::RouteRequest {
15839 intent: Some(&intent),
15840 ..crate::adaptive_router::RouteRequest::new(
15841 "Explain this architecture.",
15842 ®istry,
15843 &tracker,
15844 )
15845 });
15846 assert!(
15847 managed_ids.contains(&decision.model_id.as_str()),
15848 "managed V2-auth alias must be selectable: {decision:?}"
15849 );
15850 assert!(
15851 decision
15852 .candidates
15853 .iter()
15854 .any(|candidate| managed_ids.contains(&candidate.model_id.as_str())),
15855 "managed V2-auth alias must appear in adaptive candidates: {decision:?}"
15856 );
15857 assert!(
15858 decision
15859 .fallbacks
15860 .iter()
15861 .any(|id| managed_ids.contains(&id.as_str())),
15862 "managed V2-auth alias must appear in adaptive fallbacks: {decision:?}"
15863 );
15864 assert!(
15865 !std::iter::once(decision.model_id.as_str())
15866 .chain(
15867 decision
15868 .candidates
15869 .iter()
15870 .map(|candidate| candidate.model_id.as_str())
15871 )
15872 .chain(decision.fallbacks.iter().map(String::as_str))
15873 .any(|id| id == rogue_id),
15874 "a non-Parslee OAuth schema must never enter adaptive selection, candidates, or fallbacks: {decision:?}"
15875 );
15876
15877 let engine = InferenceEngine::new(test_config(tmp.path().join("engine-models")));
15878 let managed_lane_id = managed_ids[0];
15879 engine.lane_defaults_cache.write().unwrap().set(
15880 None,
15881 crate::intent::UseCase::Assistant,
15882 managed_lane_id.into(),
15883 1,
15884 );
15885 let request = GenerateRequest {
15886 prompt: "lane default".into(),
15887 intent: Some(IntentHint {
15888 task: Some(crate::intent::TaskHint::Chat),
15889 ..Default::default()
15890 }),
15891 ..Default::default()
15892 };
15893 assert_eq!(
15894 engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
15895 Some(managed_lane_id.to_string()),
15896 "a managed lane default must resolve from authoritative V2 auth"
15897 );
15898
15899 car_auth::logout()
15900 .await
15901 .expect("production logout must publish a signed-out tombstone");
15902 let persisted_logout: serde_json::Value =
15903 serde_json::from_str(&store.get(&state_ref).unwrap()).unwrap();
15904 assert_eq!(persisted_logout["tombstone"], true);
15905 assert_eq!(persisted_logout["accounts"], serde_json::json!([]));
15906 assert!(persisted_logout.get("active").is_none());
15907 assert_eq!(car_auth::access_token(), None);
15908
15909 registry.refresh_routing_availability(None, true);
15910 for id in managed_ids {
15911 assert!(
15912 !registry.get(id).unwrap().available_now(),
15913 "{id} must disappear from routing after the signed-out tombstone"
15914 );
15915 }
15916 assert!(
15917 registry.get(personal_id).unwrap().available_now(),
15918 "personal rows must continue to follow their independent personal-key seam"
15919 );
15920
15921 let signed_out = engine.routing_registry_snapshot().await;
15922 assert!(
15923 signed_out
15924 .list()
15925 .into_iter()
15926 .filter(|schema| schema.id.starts_with("parslee/openrouter/"))
15927 .all(|schema| !schema.available_now()),
15928 "the next engine snapshot must exclude every managed alias after logout"
15929 );
15930 assert!(
15931 signed_out.get(personal_id).unwrap().available_now(),
15932 "logout must not disable a still-keyed personal OpenRouter row"
15933 );
15934 assert_eq!(
15935 engine.lane_pin_for(&request, &signed_out),
15936 None,
15937 "a signed-out managed lane default must stop resolving"
15938 );
15939
15940 crate::openrouter::set_test_credential(None);
15941 registry.refresh_availability();
15942 assert!(
15943 !registry.get(personal_id).unwrap().available_now(),
15944 "personal row availability must still turn off with the personal-key seam"
15945 );
15946 }
15947
15948 #[tokio::test(flavor = "current_thread")]
15949 async fn legacy_parslee_auth_is_routable_only_until_v2_tombstone_then_migrates() {
15950 if !crate::run_in_isolated_test_process(
15951 "tests::legacy_parslee_auth_is_routable_only_until_v2_tombstone_then_migrates",
15952 "CAR_LEGACY_V2_MIGRATION_CHILD",
15953 ) {
15954 return;
15955 }
15956 let tmp = TempDir::new().unwrap();
15957 let _provider_env = crate::openrouter::test_environment_scope_async().await;
15958 let _restore = RestoredEnvironment::capture(&[
15959 "CAR_SECRETS_FILE_DIR",
15960 car_home::ENV_VAR,
15961 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
15962 ]);
15963 let _home = crate::openrouter::StateRootScope::new();
15978 crate::openrouter::clear_gateway_unconfigured();
15979 crate::parslee_credential::clear_credential_rejected();
15980 unsafe {
15981 std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
15982 std::env::set_var(car_home::ENV_VAR, tmp.path().join("car-home"));
15983 }
15984
15985 let secret = |key| car_secrets::SecretRef::with_default_service(key);
15986 let seed_legacy = |store: &car_secrets::SecretStore| {
15987 store
15988 .put(
15989 &secret(car_secrets::PARSLEE_ACCESS_TOKEN_KEY),
15990 "legacy-access",
15991 )
15992 .unwrap();
15993 store
15994 .put(
15995 &secret(car_secrets::PARSLEE_ACTIVE_ACCOUNT_ID_KEY),
15996 "legacy-account",
15997 )
15998 .unwrap();
15999 store.put(
16000 &secret(car_secrets::PARSLEE_ACCOUNTS_KEY),
16001 r#"{"active":"legacy-account","accounts":[{"id":"legacy-account","email":"legacy@example.test"}]}"#,
16002 )
16003 .unwrap();
16004 };
16005 let state_ref = secret(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
16006 let managed = crate::openrouter::curated_schemas()
16007 .into_iter()
16008 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
16009 .unwrap();
16010
16011 unsafe {
16012 std::env::set_var("CAR_SECRETS_FILE_DIR", tmp.path().join("tombstone-secrets"));
16013 }
16014 let tombstoned_store = car_secrets::SecretStore::new();
16015 seed_legacy(&tombstoned_store);
16016 tombstoned_store
16017 .publish(
16018 &state_ref,
16019 r#"{"schema":2,"revision":1,"generation":1,"accounts":[],"tombstone":true}"#,
16020 )
16021 .unwrap();
16022 let mut tombstoned_registry =
16023 UnifiedRegistry::new_empty(tmp.path().join("tombstoned-models"));
16024 tombstoned_registry.register_project_model(managed.clone());
16025 assert!(
16026 !tombstoned_registry
16027 .get(&managed.id)
16028 .unwrap()
16029 .available_now(),
16030 "passive catalog registration must remain disabled without a configured hint"
16031 );
16032 let tombstoned = car_auth::resolve_credential(car_auth::CredentialReadMode::Use)
16033 .await
16034 .unwrap();
16035 assert!(tombstoned.is_none(), "the V2 tombstone is authoritative");
16036 tombstoned_registry.refresh_routing_availability(None, true);
16037 assert!(!tombstoned_registry
16038 .get(&managed.id)
16039 .unwrap()
16040 .available_now());
16041
16042 unsafe {
16043 std::env::set_var(
16044 "CAR_SECRETS_FILE_DIR",
16045 tmp.path().join("legacy-only-secrets"),
16046 );
16047 }
16048 let legacy_store = car_secrets::SecretStore::new();
16049 seed_legacy(&legacy_store);
16050 assert!(!legacy_store.status(&state_ref).unwrap().exists);
16051 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("legacy-models"));
16052 registry.register_project_model(managed.clone());
16053 assert!(
16054 !registry.get(&managed.id).unwrap().available_now(),
16055 "passive registration must not inspect attributable legacy slots"
16056 );
16057 registry.refresh_availability();
16058 assert!(
16059 !registry.get(&managed.id).unwrap().available_now(),
16060 "passive refresh must remain secret-store free until request-time migration"
16061 );
16062 let resolved = car_auth::resolve_credential(car_auth::CredentialReadMode::Use)
16063 .await
16064 .unwrap()
16065 .expect("request-time auth reconciliation must migrate attributable legacy state");
16066 registry.refresh_routing_availability(Some(&resolved.api_base), false);
16067 assert!(registry.get(&managed.id).unwrap().available_now());
16068 assert!(
16069 legacy_store.status(&state_ref).unwrap().exists,
16070 "request-time auth reconciliation must publish the migrated V2 record"
16071 );
16072 assert!(
16073 !legacy_store
16074 .status(&secret(car_secrets::PARSLEE_ACCESS_TOKEN_KEY))
16075 .unwrap()
16076 .exists,
16077 "successful V2 migration must clean the legacy access slot"
16078 );
16079 }
16080
16081 #[tokio::test]
16082 async fn reviewed_openrouter_lane_default_tracks_live_credential_availability() {
16083 let _credential_scope = crate::openrouter::test_credential_scope();
16084 crate::openrouter::set_test_credential(Some("static-key"));
16085 let tmp = TempDir::new().unwrap();
16086 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16087 let reviewed_id = "openrouter/deepseek/deepseek-v3.2";
16088 engine.lane_defaults_cache.write().unwrap().set(
16089 None,
16090 crate::intent::UseCase::Assistant,
16091 reviewed_id.into(),
16092 1,
16093 );
16094 let request = GenerateRequest {
16095 prompt: "lane default".into(),
16096 intent: Some(IntentHint {
16097 task: Some(crate::intent::TaskHint::Chat),
16098 ..Default::default()
16099 }),
16100 ..Default::default()
16101 };
16102 assert_eq!(
16103 engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
16104 Some(reviewed_id.to_string()),
16105 "a reviewed keyed row must be eligible as a lane default"
16106 );
16107 crate::openrouter::set_test_credential(None);
16108 assert_eq!(
16109 engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
16110 None,
16111 "the same static lane default must stop being eligible immediately after key removal"
16112 );
16113 }
16114
16115 #[tokio::test(flavor = "current_thread")]
16116 async fn personal_openrouter_baseline_remains_visible_but_disabled_without_a_credential() {
16117 let _credential_scope = crate::openrouter::test_credential_scope();
16118 crate::openrouter::set_test_credential(None);
16119 let tmp = TempDir::new().unwrap();
16120 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16121
16122 let personal: Vec<_> = engine
16123 .list_schemas()
16124 .into_iter()
16125 .filter(|schema| schema.id.starts_with("openrouter/"))
16126 .collect();
16127 assert_eq!(
16128 personal.len(),
16129 crate::openrouter::curated_model_count(),
16130 "the vetted personal rows stay discoverable as a disabled baseline"
16131 );
16132 assert!(
16133 personal.iter().all(|schema| !schema.available_now()),
16134 "no-key baseline rows must never become routing candidates"
16135 );
16136
16137 let error = engine
16138 .generate_tracked(GenerateRequest {
16139 prompt: "hello".into(),
16140 model: Some("openrouter/openai/gpt-5.4".into()),
16141 params: GenerateParams {
16142 strict_model: true,
16143 ..Default::default()
16144 },
16145 ..Default::default()
16146 })
16147 .await
16148 .expect_err("explicitly selecting a disabled baseline row must be actionable");
16149 let message = error.to_string();
16150 assert!(
16151 message.contains("car keys set openrouter") && message.contains("CarHost"),
16152 "disabled personal row must explain how to connect OpenRouter: {message}"
16153 );
16154 }
16155
16156 #[tokio::test]
16157 async fn reviewed_openrouter_metadata_stays_static_across_credential_changes() {
16158 let _credential_scope = crate::openrouter::test_credential_scope();
16159 crate::openrouter::set_test_credential(Some("static-key"));
16160
16161 let tmp = TempDir::new().unwrap();
16162 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16163 let live = engine.routing_registry_snapshot().await;
16164 let vetted = live.get("openrouter/openai/gpt-5.4").unwrap();
16165 assert!(vetted.available_now());
16166 assert_eq!(vetted.context_length, 1_050_000);
16167 assert_eq!(vetted.max_output_tokens, Some(128_000));
16168 assert_eq!(vetted.cost.input_per_mtok, Some(2.5));
16169 assert_eq!(vetted.cost.output_per_mtok, Some(15.0));
16170 assert!(
16171 !vetted.cost.pricing_tiers.is_empty(),
16172 "reviewed high-context pricing tiers must remain in the static row"
16173 );
16174 assert_eq!(vetted.trust_tier, TrustTier::Curated);
16175 assert!(vetted.tags.iter().any(|tag| tag == "frontier"));
16176 assert!(vetted.has_capability(ModelCapability::Code));
16177 assert!(vetted
16178 .supported_params
16179 .contains(&schema::GenerateParam::ExtendedThinking));
16180
16181 assert!(live.get("openrouter/vendor/unreviewed-model").is_none());
16182
16183 crate::openrouter::set_test_credential(None);
16184 let reverted = engine.routing_registry_snapshot().await;
16185 assert!(reverted.get("openrouter/vendor/unreviewed-model").is_none());
16186 let baseline = reverted.get("openrouter/openai/gpt-5.4").unwrap();
16187 assert!(
16188 !baseline.available_now(),
16189 "credential removal must disable the reviewed row without removing it"
16190 );
16191 }
16192
16193 #[test]
16212 fn model_pulled_at_runtime_is_ready_without_a_daemon_restart() {
16213 let tmp = TempDir::new().unwrap();
16214 let models_dir = tmp.path().join("models");
16215 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
16216 engine.register_model(ModelSchema {
16217 id: "mlx/pulled-later".into(),
16218 name: "PulledLater".into(),
16219 provider: "local".into(),
16220 family: "qwen3".into(),
16221 version: "test".into(),
16222 capabilities: vec![ModelCapability::Generate, ModelCapability::Code],
16223 context_length: 4096,
16224 max_output_tokens: None,
16225 param_count: String::new(),
16226 quantization: None,
16227 performance: schema::PerformanceEnvelope::default(),
16228 cost: schema::CostModel::default(),
16229 source: ModelSource::Mlx {
16230 hf_repo: "example/pulled-later".into(),
16231 hf_weight_file: None,
16232 },
16233 tags: vec![],
16234 supported_params: vec![],
16235 public_benchmarks: vec![],
16236 trust_tier: TrustTier::Curated,
16237 deprecated: false,
16238 available: true,
16239 weights_ready: false,
16240 });
16241
16242 let engine = &engine;
16244 let is_ready = || {
16245 engine
16246 .list_schemas()
16247 .into_iter()
16248 .find(|s| s.id == "mlx/pulled-later")
16249 .expect("registered model must be listed")
16250 .weights_ready
16251 };
16252
16253 assert!(!is_ready(), "precondition: no weights on disk yet");
16254
16255 let dir = models_dir.join("PulledLater");
16258 std::fs::create_dir_all(&dir).unwrap();
16259 std::fs::write(dir.join("config.json"), "{}").unwrap();
16260 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
16261
16262 assert!(
16263 is_ready(),
16264 "a model pulled at runtime must be ready without restarting the daemon"
16265 );
16266
16267 std::fs::remove_file(dir.join("model.safetensors")).unwrap();
16270 assert!(
16271 !is_ready(),
16272 "readiness must track the disk in both directions, not latch"
16273 );
16274 }
16275
16276 #[tokio::test(flavor = "current_thread")]
16289 async fn openrouter_out_of_credits_does_not_degrade_the_model() {
16290 use wiremock::matchers::{method, path};
16291 use wiremock::{Mock, MockServer, ResponseTemplate};
16292
16293 let _credential_scope = crate::openrouter::test_credential_scope();
16294 crate::openrouter::set_test_credential(Some("test-openrouter-key"));
16295 let server = MockServer::start().await;
16296 Mock::given(method("POST"))
16297 .and(path("/v1/chat/completions"))
16298 .respond_with(
16299 ResponseTemplate::new(402)
16300 .set_body_string(r#"{"error":{"code":402,"message":"Insufficient credits"}}"#),
16301 )
16302 .mount(&server)
16303 .await;
16304
16305 let tmp = TempDir::new().unwrap();
16306 let models_dir = tmp.path().join("models");
16307 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
16308 let mut schema = crate::openrouter::curated_schemas()
16309 .into_iter()
16310 .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
16311 .unwrap();
16312 if let ModelSource::RemoteApi { endpoint, .. } = &mut schema.source {
16313 *endpoint = server.uri();
16314 }
16315 let model_id = schema.id.clone();
16316 engine.unified_registry.register_project_model(schema);
16317
16318 let err = engine
16319 .generate_tracked(GenerateRequest {
16320 prompt: "bill me".into(),
16321 model: Some(model_id.clone()),
16322 params: GenerateParams {
16323 strict_model: true,
16324 ..Default::default()
16325 },
16326 ..Default::default()
16327 })
16328 .await
16329 .expect_err("402 must not succeed");
16330
16331 match &err {
16332 InferenceError::ProviderAccount {
16333 provider, status, ..
16334 } => {
16335 assert_eq!(status, &402);
16336 assert_eq!(provider, "openrouter");
16337 }
16338 other => panic!("expected ProviderAccount, got {other:?}"),
16339 }
16340 assert!(
16341 !error_counts_against_circuit_breaker(&err),
16342 "an account rejection must not feed the per-model breaker"
16343 );
16344
16345 let tracker_handle = engine.outcome_tracker();
16347 let tracker = tracker_handle.read().await;
16348 let profile = tracker.profile(&model_id).cloned();
16349 assert!(
16350 profile.as_ref().is_none_or(|p| p.fail_count == 0),
16351 "account rejection degraded the model profile: {profile:?}"
16352 );
16353 drop(tracker);
16354
16355 let ledger = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
16359 let entry = ledger
16360 .iter()
16361 .find(|e| e.model_id == model_id)
16362 .expect("the attempt must still produce a receipt");
16363 assert!(
16364 entry.success.is_none() && entry.quality.is_none(),
16365 "receipt must record the attempt without a verdict: {entry:?}"
16366 );
16367
16368 assert!(
16370 engine
16371 .adaptive_router
16372 .circuit_breakers
16373 .lock()
16374 .unwrap()
16375 .state(&model_id)
16376 .is_none(),
16377 "account rejection must not create breaker state for the model"
16378 );
16379 }
16380
16381 #[tokio::test(flavor = "current_thread")]
16382 async fn openrouter_stream_error_records_failure_and_never_completes_successfully() {
16383 use wiremock::matchers::{method, path};
16384 use wiremock::{Mock, MockServer, ResponseTemplate};
16385
16386 let _credential_scope = crate::openrouter::test_credential_scope();
16387 crate::openrouter::set_test_credential(Some("test-openrouter-key"));
16388 let server = MockServer::start().await;
16389 Mock::given(method("POST"))
16390 .and(path("/v1/chat/completions"))
16391 .respond_with(ResponseTemplate::new(200).set_body_raw(
16392 "data: {\"error\":{\"code\":402,\"message\":\"private balance details\"}}\n\n",
16393 "text/event-stream",
16394 ))
16395 .mount(&server)
16396 .await;
16397
16398 let tmp = TempDir::new().unwrap();
16399 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16400 let mut schema = crate::openrouter::curated_schemas()
16401 .into_iter()
16402 .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
16403 .unwrap();
16404 if let ModelSource::RemoteApi { endpoint, .. } = &mut schema.source {
16405 *endpoint = server.uri();
16406 }
16407 let model_id = schema.id.clone();
16408 engine.unified_registry.register_project_model(schema);
16409
16410 let mut handle = engine
16411 .generate_tracked_stream(GenerateRequest {
16412 prompt: "fail after headers".into(),
16413 model: Some(model_id.clone()),
16414 ..Default::default()
16415 })
16416 .await
16417 .unwrap();
16418 let mut events = Vec::new();
16419 while let Some(event) = handle.events.recv().await {
16420 events.push(event);
16421 }
16422 assert!(matches!(
16423 events.as_slice(),
16424 [StreamEvent::Error(message)] if message == "OpenRouter account is out of credits"
16425 ));
16426 assert!(!events
16427 .iter()
16428 .any(|event| matches!(event, StreamEvent::Done { .. })));
16429
16430 for _ in 0..50 {
16431 if engine
16432 .outcome_tracker()
16433 .read()
16434 .await
16435 .profile(&model_id)
16436 .is_some_and(|profile| profile.fail_count == 1)
16437 {
16438 break;
16439 }
16440 tokio::task::yield_now().await;
16441 }
16442 let tracker = engine.outcome_tracker();
16443 let profile = tracker.read().await.profile(&model_id).cloned().unwrap();
16444 assert_eq!(profile.fail_count, 1);
16445 assert_eq!(profile.success_count, 0);
16446 }
16447
16448 #[tokio::test(flavor = "current_thread")]
16449 async fn tracked_stream_without_done_records_failure_not_success() {
16450 let _offload_guard = crate::offload::test_offload_lock().lock().await;
16451 let tmp = TempDir::new().unwrap();
16452 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16453 pin_test_live_memory(&mut engine);
16454 let model_id = install_small_local_fixture(&engine);
16455 crate::offload::set_local_offload(Some(Arc::new(FixtureLocalOffload { emit_done: false })));
16456
16457 let mut stream = engine
16458 .generate_tracked_stream(GenerateRequest {
16459 prompt: "must not count as success".into(),
16460 model: Some(model_id.clone()),
16461 params: GenerateParams {
16462 strict_model: true,
16463 ..Default::default()
16464 },
16465 ..Default::default()
16466 })
16467 .await
16468 .unwrap();
16469 while stream.events.recv().await.is_some() {}
16470 crate::offload::set_local_offload(None);
16471
16472 for _ in 0..50 {
16473 if engine
16474 .outcome_tracker()
16475 .read()
16476 .await
16477 .profile(&model_id)
16478 .is_some_and(|profile| profile.fail_count == 1)
16479 {
16480 break;
16481 }
16482 tokio::task::yield_now().await;
16483 }
16484 let profile = engine
16485 .outcome_tracker()
16486 .read()
16487 .await
16488 .profile(&model_id)
16489 .cloned()
16490 .unwrap();
16491 assert_eq!(profile.fail_count, 1);
16492 assert_eq!(profile.success_count, 0);
16493 }
16494
16495 #[tokio::test(flavor = "current_thread")]
16496 async fn google_vertex_terminal_matrix_records_only_deliberate_finishes_as_success() {
16497 use wiremock::matchers::{method, path};
16498 use wiremock::{Mock, MockServer, ResponseTemplate};
16499
16500 let _provider_env = crate::openrouter::test_environment_scope_async().await;
16501 let _env = ENV_MUTEX.lock().await;
16502 unsafe { std::env::set_var("CAR_GOOGLE_OUTCOME_MATRIX_KEY", "matrix-key") };
16503
16504 for (protocol, reason, should_succeed) in [
16505 (schema::ApiProtocol::Google, "STOP", true),
16506 (schema::ApiProtocol::Google, "SAFETY", false),
16507 (schema::ApiProtocol::VertexAi, "MAX_TOKENS", true),
16508 (
16509 schema::ApiProtocol::VertexAi,
16510 "MALFORMED_FUNCTION_CALL",
16511 false,
16512 ),
16513 ] {
16514 let server = MockServer::start().await;
16515 let expected_path = match protocol {
16516 schema::ApiProtocol::Google => "/v1beta/models/gemini-test:streamGenerateContent",
16517 schema::ApiProtocol::VertexAi => {
16518 "/publishers/google/models/gemini-test:streamGenerateContent"
16519 }
16520 _ => unreachable!(),
16521 };
16522 Mock::given(method("POST"))
16523 .and(path(expected_path))
16524 .respond_with(ResponseTemplate::new(200).set_body_raw(
16525 format!(
16526 "data: {{\"candidates\":[{{\"content\":{{\"parts\":[{{\"text\":\"matrix\"}}]}},\"finishReason\":\"{reason}\"}}]}}\n\n"
16527 ),
16528 "text/event-stream",
16529 ))
16530 .mount(&server)
16531 .await;
16532
16533 let id = format!("test/{protocol:?}-{reason}");
16534 let tmp = TempDir::new().unwrap();
16535 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16536 engine.register_model(remote_stream_fixture_schema(
16537 &id,
16538 server.uri(),
16539 protocol,
16540 "CAR_GOOGLE_OUTCOME_MATRIX_KEY",
16541 ));
16542 let mut stream = engine
16543 .generate_tracked_stream(GenerateRequest {
16544 prompt: "matrix".into(),
16545 model: Some(id.clone()),
16546 params: GenerateParams {
16547 strict_model: true,
16548 ..Default::default()
16549 },
16550 ..Default::default()
16551 })
16552 .await
16553 .unwrap();
16554 let mut events = Vec::new();
16555 while let Some(event) = stream.events.recv().await {
16556 events.push(event);
16557 }
16558 for _ in 0..50 {
16559 if engine
16560 .outcome_tracker()
16561 .read()
16562 .await
16563 .profile(&id)
16564 .is_some_and(|profile| profile.total_calls == 1)
16565 {
16566 break;
16567 }
16568 tokio::task::yield_now().await;
16569 }
16570 let profile = engine
16571 .outcome_tracker()
16572 .read()
16573 .await
16574 .profile(&id)
16575 .cloned()
16576 .unwrap();
16577 assert_eq!(
16578 (profile.success_count, profile.fail_count),
16579 if should_succeed { (1, 0) } else { (0, 1) },
16580 "{protocol:?}/{reason}: {events:?}"
16581 );
16582 }
16583
16584 unsafe { std::env::remove_var("CAR_GOOGLE_OUTCOME_MATRIX_KEY") };
16585 }
16586
16587 #[tokio::test(flavor = "current_thread")]
16588 async fn remote_primary_stream_setup_failure_falls_back_to_installed_local_dispatch() {
16589 use wiremock::matchers::{method, path};
16590 use wiremock::{Mock, MockServer, ResponseTemplate};
16591
16592 let _offload_guard = crate::offload::test_offload_lock().lock().await;
16593 let _env = ENV_MUTEX.lock().await;
16594 let server = MockServer::start().await;
16595 Mock::given(method("POST"))
16596 .and(path("/v1/chat/completions"))
16597 .respond_with(ResponseTemplate::new(503))
16598 .mount(&server)
16599 .await;
16600 unsafe { std::env::set_var("CAR_STREAM_FALLBACK_TEST_KEY", "fixture") };
16601
16602 let tmp = TempDir::new().unwrap();
16603 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16604 pin_test_live_memory(&mut engine);
16605 let _local_id = install_small_local_fixture(&engine);
16606 let remote_id = "test/remote-primary";
16607 engine.register_model(ModelSchema {
16608 id: remote_id.into(),
16609 name: "remote-primary".into(),
16610 provider: "test".into(),
16611 family: "test".into(),
16612 version: "1".into(),
16613 capabilities: vec![ModelCapability::Generate],
16614 context_length: 8_192,
16615 max_output_tokens: Some(1_024),
16616 param_count: String::new(),
16617 quantization: None,
16618 performance: Default::default(),
16619 cost: Default::default(),
16620 source: ModelSource::RemoteApi {
16621 endpoint: server.uri(),
16622 api_key_env: "CAR_STREAM_FALLBACK_TEST_KEY".into(),
16623 api_key_envs: vec![],
16624 api_version: None,
16625 protocol: schema::ApiProtocol::OpenAiCompat,
16626 },
16627 tags: vec![],
16628 supported_params: vec![],
16629 public_benchmarks: vec![],
16630 trust_tier: TrustTier::Community,
16631 deprecated: false,
16632 available: true,
16633 weights_ready: true,
16634 });
16635 crate::offload::set_local_offload(Some(Arc::new(FixtureLocalOffload { emit_done: true })));
16636
16637 let mut stream = engine
16638 .generate_tracked_stream(GenerateRequest {
16639 prompt: "fall back".into(),
16640 model: Some(remote_id.into()),
16641 ..Default::default()
16642 })
16643 .await
16644 .expect("compatible installed local model should be dispatched");
16645 assert_ne!(stream.model_used, remote_id);
16646 assert!(
16647 engine
16648 .unified_registry
16649 .get(&stream.model_used)
16650 .is_some_and(ModelSchema::is_local),
16651 "fallback must stay on a compatible local model: {}",
16652 stream.model_used
16653 );
16654 let mut saw_done = false;
16655 while let Some(event) = stream.events.recv().await {
16656 saw_done |= matches!(event, StreamEvent::Done { .. });
16657 }
16658 assert!(saw_done);
16659
16660 crate::offload::set_local_offload(None);
16661 unsafe { std::env::remove_var("CAR_STREAM_FALLBACK_TEST_KEY") };
16662 }
16663
16664 #[tokio::test(flavor = "current_thread")]
16665 async fn exact_model_id_rejects_display_name_without_dispatch() {
16666 let tmp = TempDir::new().unwrap();
16667 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16668 let mut first = remote_stream_fixture_schema(
16669 "test/ambiguous-a:1",
16670 "http://127.0.0.1:9".into(),
16671 schema::ApiProtocol::OpenAiCompat,
16672 "CAR_AMBIGUOUS_EXACT_KEY",
16673 );
16674 first.name = "Shared Alias".into();
16675 let mut second = first.clone();
16676 second.id = "test/ambiguous-b:1".into();
16677 engine.register_model(first);
16678 engine.register_model(second);
16679 let mut request = GenerateRequest {
16680 prompt: "must not dispatch".into(),
16681 ..Default::default()
16682 };
16683 pin_exact_model_id(&mut request, "Shared Alias".into()).unwrap();
16684
16685 let error = engine
16686 .generate_tracked(request)
16687 .await
16688 .expect_err("an exact-id pin must never resolve a display name");
16689 assert!(matches!(error, InferenceError::ModelNotFound(_)));
16690 }
16691
16692 #[tokio::test(flavor = "current_thread")]
16693 async fn exact_openai_pin_reports_catalog_id_and_loose_request_keeps_provider_name() {
16694 let _env = ENV_MUTEX.lock().await;
16695 assert_remote_model_identity_contract(
16696 schema::ApiProtocol::OpenAiCompat,
16697 "openai/newsroom-gpt-5.5-2026-04-23:test",
16698 "newsroom-gpt-5.5-2026-04-23-test",
16699 "CAR_OPENAI_IDENTITY_TEST_KEY",
16700 )
16701 .await;
16702 }
16703
16704 #[tokio::test(flavor = "current_thread")]
16705 async fn exact_anthropic_pin_reports_catalog_id_and_loose_request_keeps_provider_name() {
16706 let _env = ENV_MUTEX.lock().await;
16707 assert_remote_model_identity_contract(
16708 schema::ApiProtocol::Anthropic,
16709 "anthropic/newsroom-claude-opus-4-8:test",
16710 "newsroom-claude-opus-4-8-test",
16711 "CAR_ANTHROPIC_IDENTITY_TEST_KEY",
16712 )
16713 .await;
16714 }
16715
16716 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
16717 #[tokio::test(flavor = "current_thread")]
16718 async fn exact_model_id_nonstream_bypasses_mlx_equivalent_substitution() {
16719 let _offload_guard = crate::offload::test_offload_lock().lock().await;
16720 let tmp = TempDir::new().unwrap();
16721 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16722 let exact_id = install_exact_pin_equivalent_fixture(&engine);
16723 let offload = Arc::new(ExactPinCaptureOffload::new());
16724 crate::offload::set_local_offload(Some(offload.clone()));
16725
16726 let mut request = GenerateRequest {
16727 prompt: "use only the immutable row I selected".into(),
16728 ..Default::default()
16729 };
16730 pin_exact_model_id(&mut request, exact_id.clone()).unwrap();
16731 let error = engine.generate_tracked(request).await.unwrap_err();
16732
16733 assert!(offload.dispatched_models().is_empty());
16734 assert!(error.to_string().contains(&exact_id), "{error}");
16735 assert!(
16736 error
16737 .to_string()
16738 .contains("MLX-equivalent substitution is disabled for exact pins"),
16739 "{error}"
16740 );
16741 crate::offload::set_local_offload(None);
16742 }
16743
16744 #[tokio::test(flavor = "current_thread")]
16745 async fn catalog_identity_mismatch_rejects_before_provider_dispatch() {
16746 use wiremock::matchers::{method, path};
16747 use wiremock::{Mock, MockServer, ResponseTemplate};
16748
16749 let _env = ENV_MUTEX.lock().await;
16750 let server = MockServer::start().await;
16751 Mock::given(method("POST"))
16752 .and(path("/v1/chat/completions"))
16753 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
16754 "choices": [{"message": {"content": "must not run"}, "finish_reason": "stop"}],
16755 })))
16756 .mount(&server)
16757 .await;
16758 unsafe { std::env::set_var("CAR_PRECONDITION_TEST_KEY", "fixture") };
16759
16760 let tmp = TempDir::new().unwrap();
16761 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16762 let model_id = "test/catalog-precondition:1";
16763 engine.register_model(remote_stream_fixture_schema(
16764 model_id,
16765 server.uri(),
16766 schema::ApiProtocol::OpenAiCompat,
16767 "CAR_PRECONDITION_TEST_KEY",
16768 ));
16769 let snapshot = engine.catalog_snapshot().unwrap();
16770 let row_digest = snapshot
16771 .model_by_exact_id(model_id)
16772 .unwrap()
16773 .row_digest
16774 .clone();
16775
16776 for (expected_row_digest, expected_catalog_revision) in [
16777 (
16778 Some("0".repeat(64)),
16779 Some(snapshot.catalog_revision.clone()),
16780 ),
16781 (Some(row_digest.clone()), Some("f".repeat(64))),
16782 ] {
16783 let mut request = GenerateRequest {
16784 prompt: "must not dispatch".into(),
16785 expected_row_digest,
16786 expected_catalog_revision,
16787 ..Default::default()
16788 };
16789 pin_exact_model_id(&mut request, model_id.into()).unwrap();
16790 let error = engine.generate_tracked(request).await.unwrap_err();
16791 assert!(matches!(
16792 error,
16793 InferenceError::CatalogPreconditionMismatch { .. }
16794 ));
16795 }
16796 assert!(
16797 server.received_requests().await.unwrap().is_empty(),
16798 "catalog identity mismatches must fail before provider dispatch"
16799 );
16800
16801 unsafe { std::env::remove_var("CAR_PRECONDITION_TEST_KEY") };
16802 }
16803
16804 #[tokio::test(flavor = "current_thread")]
16805 async fn exact_nonstream_identity_survives_thinking_retry() {
16806 use std::sync::atomic::{AtomicUsize, Ordering};
16807 use wiremock::matchers::{method, path};
16808 use wiremock::{Mock, MockServer, ResponseTemplate};
16809
16810 let _env = ENV_MUTEX.lock().await;
16811 let server = MockServer::start().await;
16812 let calls = Arc::new(AtomicUsize::new(0));
16813 let response_calls = calls.clone();
16814 Mock::given(method("POST"))
16815 .and(path("/v1/chat/completions"))
16816 .respond_with(move |_request: &wiremock::Request| {
16817 let attempt = response_calls.fetch_add(1, Ordering::SeqCst);
16818 let content = if attempt == 0 { "" } else { "recovered" };
16819 ResponseTemplate::new(200).set_body_json(serde_json::json!({
16820 "choices": [{
16821 "message": {"content": content},
16822 "finish_reason": if attempt == 0 { "length" } else { "stop" },
16823 }],
16824 "usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
16825 }))
16826 })
16827 .mount(&server)
16828 .await;
16829 unsafe { std::env::set_var("CAR_IDENTITY_RETRY_KEY", "fixture") };
16830
16831 let tmp = TempDir::new().unwrap();
16832 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16833 let model_id = "test/identity-retry:1";
16834 engine.register_model(remote_stream_fixture_schema(
16835 model_id,
16836 server.uri(),
16837 schema::ApiProtocol::OpenAiCompat,
16838 "CAR_IDENTITY_RETRY_KEY",
16839 ));
16840 let snapshot = engine.catalog_snapshot().unwrap();
16841 let expected_row = snapshot.model_by_exact_id(model_id).unwrap();
16842 let mut request = GenerateRequest {
16843 prompt: "retry once".into(),
16844 expected_row_digest: Some(expected_row.row_digest.clone()),
16845 expected_catalog_revision: Some(snapshot.catalog_revision.clone()),
16846 ..Default::default()
16847 };
16848 pin_exact_model_id(&mut request, model_id.into()).unwrap();
16849
16850 let result = engine.generate_tracked(request).await.unwrap();
16851 assert_eq!(calls.load(Ordering::SeqCst), 2, "must exercise retry path");
16852 assert_eq!(result.stop_reason.as_deref(), Some("thinking_recovered"));
16853 assert_eq!(
16854 result.model_identity.requested_model_id.as_deref(),
16855 Some(model_id)
16856 );
16857 assert_eq!(result.model_identity.resolved_model_id, model_id);
16858 assert_eq!(result.model_identity.row_digest, expected_row.row_digest);
16859 assert_eq!(
16860 result.model_identity.catalog_revision,
16861 snapshot.catalog_revision
16862 );
16863
16864 unsafe { std::env::remove_var("CAR_IDENTITY_RETRY_KEY") };
16865 }
16866
16867 #[tokio::test(flavor = "current_thread")]
16868 async fn managed_openrouter_reasoning_items_roundtrip_verbatim_across_two_turns() {
16869 if !crate::run_in_isolated_test_process(
16870 "tests::managed_openrouter_reasoning_items_roundtrip_verbatim_across_two_turns",
16871 "CAR_MANAGED_REASONING_ROUNDTRIP_CHILD",
16872 ) {
16873 return;
16874 }
16875 let _home = crate::openrouter::StateRootScope::new();
16876 use wiremock::matchers::{header, method, path};
16877 use wiremock::{Mock, MockServer, ResponseTemplate};
16878
16879 let _provider_env = crate::openrouter::test_environment_scope_async().await;
16880 let _env = ENV_MUTEX.lock().await;
16881 let bearer = "managed-reasoning-roundtrip-bearer";
16882
16883 let server = MockServer::start().await;
16884 unsafe {
16885 std::env::set_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV, bearer);
16886 std::env::set_var(car_auth::PARSLEE_API_BASE_KEY, server.uri());
16887 }
16888 Mock::given(method("GET"))
16889 .and(path("/api/v1/organizations/me"))
16890 .and(header("authorization", format!("Bearer {bearer}")))
16891 .respond_with(
16892 ResponseTemplate::new(200)
16893 .set_body_json(serde_json::json!({"organizationId": "org-roundtrip"})),
16894 )
16895 .mount(&server)
16896 .await;
16897 Mock::given(method("GET"))
16898 .and(path("/connect/session"))
16899 .respond_with(
16900 ResponseTemplate::new(200)
16901 .set_body_json(serde_json::json!({"account": {"email": "user@example.test"}})),
16902 )
16903 .mount(&server)
16904 .await;
16905 Mock::given(method("POST"))
16906 .and(path("/api/v1/orgs/org-roundtrip/inference/responses"))
16907 .respond_with(ResponseTemplate::new(200).set_body_raw(
16908 include_str!("../tests/fixtures/parslee-openrouter-reasoning-roundtrip.sse"),
16909 "text/event-stream",
16910 ))
16911 .expect(2)
16912 .mount(&server)
16913 .await;
16914
16915 let tmp = TempDir::new().unwrap();
16916 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16917 let schema = crate::openrouter::curated_schemas()
16918 .into_iter()
16919 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
16920 .unwrap();
16921 engine.unified_registry.register_project_model(schema);
16922
16923 let first = engine
16924 .generate_tracked(GenerateRequest {
16925 prompt: "first".into(),
16926 model: Some("parslee/openrouter/frontier-general".into()),
16927 ..Default::default()
16928 })
16929 .await
16930 .expect("first managed turn");
16931 let expected_reasoning = serde_json::json!({
16932 "type": "reasoning",
16933 "id": "rs_car_roundtrip",
16934 "status": "completed",
16935 "summary": [{"type": "summary_text", "text": "safe summary"}],
16936 "encrypted_content": "opaque-encrypted-reasoning",
16937 });
16938 assert_eq!(
16939 first.provider_output_items,
16940 vec![expected_reasoning.clone()]
16941 );
16942
16943 let mut history = vec![Message::User {
16944 content: "first".into(),
16945 }];
16946 first.append_assistant_history(&mut history, first.tool_calls.clone());
16947 history.push(Message::User {
16948 content: "continue".into(),
16949 });
16950
16951 let second = engine
16952 .generate_tracked(GenerateRequest {
16953 prompt: String::new(),
16954 model: Some("parslee/openrouter/frontier-general".into()),
16955 messages: Some(history),
16956 ..Default::default()
16957 })
16958 .await
16959 .expect("second managed turn");
16960 assert_eq!(second.text, "first answer");
16961
16962 let requests = server.received_requests().await.unwrap();
16963 let posts: Vec<serde_json::Value> = requests
16964 .iter()
16965 .filter(|request| {
16966 request.method.as_str() == "POST"
16967 && request.url.path() == "/api/v1/orgs/org-roundtrip/inference/responses"
16968 })
16969 .map(|request| serde_json::from_slice(&request.body).unwrap())
16970 .collect();
16971 assert_eq!(posts.len(), 2);
16972 for body in &posts {
16973 assert_eq!(body["store"], false);
16974 assert_eq!(
16975 body["include"],
16976 serde_json::json!(["reasoning.encrypted_content"])
16977 );
16978 }
16979 let second_input = posts[1]["input"].as_array().unwrap();
16980 let reasoning_index = second_input
16981 .iter()
16982 .position(|item| item == &expected_reasoning)
16983 .expect("second request must replay the exact reasoning item");
16984 let assistant_index = second_input
16985 .iter()
16986 .position(|item| item["role"] == "assistant")
16987 .expect("second request assistant turn");
16988 let user_index = second_input
16989 .iter()
16990 .position(|item| item["content"] == "continue")
16991 .expect("second request user turn");
16992 assert!(
16993 reasoning_index < assistant_index && assistant_index < user_index,
16994 "provider output order must be reasoning, assistant text, then the next user turn"
16995 );
16996
16997 unsafe {
16998 std::env::remove_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV);
16999 std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
17000 }
17001 }
17002
17003 #[tokio::test(flavor = "current_thread")]
17004 async fn managed_partial_eof_fails_buffered_and_streamed_turns_and_records_only_failures() {
17005 if !crate::run_in_isolated_test_process(
17006 "tests::managed_partial_eof_fails_buffered_and_streamed_turns_and_records_only_failures",
17007 "CAR_MANAGED_PARTIAL_EOF_CHILD",
17008 ) {
17009 return;
17010 }
17011 let _home = crate::openrouter::StateRootScope::new();
17012 use wiremock::matchers::{header, method, path};
17013 use wiremock::{Mock, MockServer, ResponseTemplate};
17014
17015 let _provider_env = crate::openrouter::test_environment_scope_async().await;
17016 let _env = ENV_MUTEX.lock().await;
17017 let bearer = "managed-partial-outcome-bearer";
17018
17019 let server = MockServer::start().await;
17020 unsafe {
17021 std::env::set_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV, bearer);
17022 std::env::set_var(car_auth::PARSLEE_API_BASE_KEY, server.uri());
17023 }
17024 Mock::given(method("GET"))
17025 .and(path("/api/v1/organizations/me"))
17026 .and(header("authorization", format!("Bearer {bearer}")))
17027 .respond_with(
17028 ResponseTemplate::new(200)
17029 .set_body_json(serde_json::json!({"organizationId": "org-partial-outcome"})),
17030 )
17031 .mount(&server)
17032 .await;
17033 Mock::given(method("GET"))
17034 .and(path("/connect/session"))
17035 .respond_with(
17036 ResponseTemplate::new(200)
17037 .set_body_json(serde_json::json!({"account": {"email": "user@example.test"}})),
17038 )
17039 .mount(&server)
17040 .await;
17041 Mock::given(method("POST"))
17042 .and(path("/api/v1/orgs/org-partial-outcome/inference/responses"))
17043 .respond_with(ResponseTemplate::new(200).set_body_raw(
17044 "event: response.output_text.delta\ndata: {\"delta\":\"partial must fail\"}\n\n",
17045 "text/event-stream",
17046 ))
17047 .expect(2)
17048 .mount(&server)
17049 .await;
17050
17051 let tmp = TempDir::new().unwrap();
17052 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17053 let model_id = "parslee/openrouter/frontier-general";
17054 let schema = crate::openrouter::curated_schemas()
17055 .into_iter()
17056 .find(|schema| schema.id == model_id)
17057 .unwrap();
17058 engine.unified_registry.register_project_model(schema);
17059
17060 let buffered_error = engine
17061 .generate_tracked(GenerateRequest {
17062 prompt: "buffered".into(),
17063 model: Some(model_id.into()),
17064 params: GenerateParams {
17065 strict_model: true,
17066 ..Default::default()
17067 },
17068 ..Default::default()
17069 })
17070 .await
17071 .expect_err("buffered partial EOF must fail");
17072 assert!(
17073 buffered_error.to_string().contains("response.completed"),
17074 "unexpected buffered error: {buffered_error}"
17075 );
17076
17077 let mut stream = engine
17078 .generate_tracked_stream(GenerateRequest {
17079 prompt: "streamed".into(),
17080 model: Some(model_id.into()),
17081 params: GenerateParams {
17082 strict_model: true,
17083 ..Default::default()
17084 },
17085 ..Default::default()
17086 })
17087 .await
17088 .expect("HTTP streaming request starts");
17089 let mut events = Vec::new();
17090 while let Some(event) = stream.events.recv().await {
17091 events.push(event);
17092 }
17093 assert!(
17094 matches!(events.last(), Some(StreamEvent::Error(message)) if message.contains("response.completed"))
17095 );
17096 assert!(!events
17097 .iter()
17098 .any(|event| matches!(event, StreamEvent::Done { .. })));
17099
17100 for _ in 0..50 {
17101 if engine
17102 .outcome_tracker()
17103 .read()
17104 .await
17105 .profile(model_id)
17106 .is_some_and(|profile| profile.fail_count == 2)
17107 {
17108 break;
17109 }
17110 tokio::task::yield_now().await;
17111 }
17112 let profile = engine
17113 .outcome_tracker()
17114 .read()
17115 .await
17116 .profile(model_id)
17117 .cloned()
17118 .unwrap();
17119 assert_eq!(profile.fail_count, 2);
17120 assert_eq!(profile.success_count, 0);
17121
17122 unsafe {
17123 std::env::remove_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV);
17124 std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
17125 }
17126 }
17127
17128 #[tokio::test]
17129 async fn tokenize_rejects_known_remote_model_with_unsupported_mode() {
17130 let tmp = TempDir::new().unwrap();
17135 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17136 let remote_id = engine
17137 .list_schemas()
17138 .into_iter()
17139 .find(|s| !s.is_local())
17140 .map(|s| s.id)
17141 .expect("built-in catalog should include at least one remote model schema");
17142
17143 let err = engine
17144 .tokenize(&remote_id, "hello")
17145 .await
17146 .expect_err("remote tokenize must error");
17147 match err {
17148 InferenceError::UnsupportedMode { mode, backend, .. } => {
17149 assert_eq!(mode, "tokenize/detokenize");
17150 assert_eq!(backend, "remote");
17151 }
17152 other => panic!("expected UnsupportedMode, got {other:?}"),
17153 }
17154
17155 let err = engine
17156 .detokenize(&remote_id, &[1, 2, 3])
17157 .await
17158 .expect_err("remote detokenize must error");
17159 assert!(
17160 matches!(err, InferenceError::UnsupportedMode { .. }),
17161 "expected UnsupportedMode, got {err:?}"
17162 );
17163 }
17164
17165 #[test]
17166 fn unsupported_mode_does_not_trip_circuit_breaker() {
17167 let unsupported = InferenceError::UnsupportedMode {
17173 mode: "structured-output-json-schema",
17174 backend: "anthropic",
17175 reason: "not wired under the pinned API version",
17176 };
17177 assert!(!error_counts_against_circuit_breaker(&unsupported));
17178 assert!(error_counts_against_circuit_breaker(
17179 &InferenceError::InferenceFailed("API returned 500".into())
17180 ));
17181 assert!(error_counts_against_circuit_breaker(
17182 &InferenceError::InferenceFailed("API returned 429".into())
17183 ));
17184 }
17185
17186 #[test]
17194 fn a_content_refusal_does_not_trip_the_circuit_breaker() {
17195 let refused = InferenceError::ContentRefused {
17196 provider: "parslee".into(),
17197 kind: Some("invalid_request_error".into()),
17198 code: Some("content_policy_violation".into()),
17199 message: "content refused".into(),
17200 };
17201 assert!(!error_counts_against_circuit_breaker(&refused));
17202
17203 let rendered = refused.to_string();
17206 assert!(rendered.contains("content grounds"), "{rendered}");
17207 assert!(rendered.contains("content_policy_violation"), "{rendered}");
17208
17209 assert!(error_counts_against_circuit_breaker(
17211 &InferenceError::InferenceFailed("managed inference failed".into())
17212 ));
17213 }
17214
17215 #[test]
17225 fn a_content_refusal_ends_the_fallback_chain() {
17226 let refused = InferenceError::ContentRefused {
17227 provider: "parslee".into(),
17228 kind: Some("invalid_request_error".into()),
17229 code: Some("content_policy_violation".into()),
17230 message: "content refused".into(),
17231 };
17232 assert!(error_ends_fallback_chain(&refused));
17233
17234 for still_advances in [
17238 InferenceError::InferenceFailed("managed inference failed".into()),
17239 InferenceError::InferenceFailed("API returned 503".into()),
17240 InferenceError::ModelNotFound("parslee/reasoning".into()),
17241 InferenceError::UnsupportedMode {
17242 mode: "json_schema",
17243 backend: "anthropic",
17244 reason: "structured output is not supported by this protocol",
17245 },
17246 InferenceError::GatewayUnconfigured {
17247 provider: "parslee".into(),
17248 namespace: "parslee/openrouter/".into(),
17249 status: 503,
17250 message: "not configured".into(),
17251 },
17252 ] {
17253 assert!(
17254 !error_ends_fallback_chain(&still_advances),
17255 "must keep advancing the chain: {still_advances}"
17256 );
17257 }
17258 }
17259
17260 #[test]
17269 fn recovery_hints_do_not_rewrite_a_content_refusal() {
17270 let refused = InferenceError::ContentRefused {
17271 provider: "parslee".into(),
17272 kind: Some("invalid_request_error".into()),
17273 code: Some("content_policy_violation".into()),
17274 message: "blocked: 403 forbidden by the content filter".into(),
17276 };
17277 let out = apply_exhaustion_recovery_hint(refused);
17278 assert!(
17279 matches!(out, InferenceError::ContentRefused { .. }),
17280 "{out:?}"
17281 );
17282
17283 let signed_out = apply_exhaustion_recovery_hint(InferenceError::InferenceFailed(
17285 "no credential for proprietary provider 'parslee'".into(),
17286 ));
17287 assert!(
17288 matches!(signed_out, InferenceError::InferenceFailed(ref m) if m.contains("car auth")),
17289 "{signed_out:?}"
17290 );
17291
17292 let transient = apply_exhaustion_recovery_hint(InferenceError::InferenceFailed(
17294 "API returned 500".into(),
17295 ));
17296 assert_eq!(transient.to_string(), "inference failed: API returned 500");
17297 }
17298
17299 #[test]
17307 fn unconfigured_gateway_does_not_trip_circuit_breaker() {
17308 let unconfigured = InferenceError::GatewayUnconfigured {
17309 provider: "parslee".into(),
17310 namespace: "parslee/openrouter/".into(),
17311 status: 503,
17312 message: "OpenRouter inference is not configured on this Parslee environment.".into(),
17313 };
17314 assert!(!error_counts_against_circuit_breaker(&unconfigured));
17315 let rendered = unconfigured.to_string();
17319 assert!(rendered.contains("parslee/openrouter/"), "{rendered}");
17320 assert!(rendered.contains("not configured"), "{rendered}");
17321 }
17322
17323 #[test]
17329 fn namespace_drop_spares_siblings_outside_the_prefix() {
17330 let namespace = "parslee/openrouter/";
17331 let mut queue: std::collections::VecDeque<String> = [
17332 "parslee/openrouter/open-fast",
17333 "parslee/reasoning",
17334 "parslee/openrouter/frontier-general",
17335 "parslee/advisor",
17336 "anthropic/claude-opus-4-8:latest",
17337 ]
17338 .into_iter()
17339 .map(String::from)
17340 .collect();
17341
17342 queue.retain(|id| !id.starts_with(namespace));
17343
17344 assert_eq!(
17345 queue.iter().collect::<Vec<_>>(),
17346 vec![
17347 "parslee/reasoning",
17348 "parslee/advisor",
17349 "anthropic/claude-opus-4-8:latest"
17350 ],
17351 "only the unconfigured namespace may be dropped"
17352 );
17353 }
17354
17355 #[test]
17356 fn engine_loads_benchmark_priors_on_startup() {
17357 let _env = ENV_MUTEX.blocking_lock();
17358 let tmp = TempDir::new().unwrap();
17359 let priors_path = tmp.path().join("benchmark_priors.json");
17360 std::fs::write(
17361 &priors_path,
17362 serde_json::json!({
17363 "model_id": "qwen/qwen3-8b:q4_k_m",
17364 "overall_score": 0.88
17365 })
17366 .to_string(),
17367 )
17368 .unwrap();
17369
17370 unsafe {
17371 std::env::set_var("CAR_BENCHMARK_PRIORS_PATH", &priors_path);
17372 }
17373
17374 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17375 let tracker = engine.outcome_tracker.blocking_read();
17376 let profile = tracker
17377 .profile("qwen/qwen3-8b:q4_k_m")
17378 .expect("benchmark prior should create a profile");
17379 assert!((profile.ema_quality - 0.88).abs() < 0.01);
17380
17381 unsafe {
17382 std::env::remove_var("CAR_BENCHMARK_PRIORS_PATH");
17383 }
17384 }
17385
17386 #[test]
17387 fn benchmark_priors_do_not_override_observed_profiles() {
17388 let _env = ENV_MUTEX.blocking_lock();
17389 let tmp = TempDir::new().unwrap();
17390 let models_dir = tmp.path().join("models");
17391 std::fs::create_dir_all(&models_dir).unwrap();
17392
17393 let observed = vec![ModelProfile {
17394 model_id: "qwen/qwen3-8b:q4_k_m".into(),
17395 total_calls: 12,
17396 success_count: 3,
17397 fail_count: 9,
17398 total_latency_ms: 1200,
17399 total_input_tokens: 0,
17400 total_output_tokens: 0,
17401 total_cache_read_input_tokens: 0,
17402 total_cache_creation_input_tokens: 0,
17403 task_stats: std::collections::HashMap::new(),
17404 ema_quality: 0.21,
17405 prior_sample_size: 0,
17406 quality_observations: 0,
17407 quality_per_1k_tokens: 0.0,
17408 updated_at: 1,
17409 }];
17410 std::fs::write(
17411 models_dir.join("outcome_profiles.json"),
17412 serde_json::to_string(&observed).unwrap(),
17413 )
17414 .unwrap();
17415
17416 let priors_path = tmp.path().join("benchmark_priors.json");
17417 std::fs::write(
17418 &priors_path,
17419 serde_json::json!({
17420 "model_id": "qwen/qwen3-8b:q4_k_m",
17421 "overall_score": 0.95
17422 })
17423 .to_string(),
17424 )
17425 .unwrap();
17426
17427 unsafe {
17428 std::env::set_var("CAR_BENCHMARK_PRIORS_PATH", &priors_path);
17429 }
17430
17431 let engine = InferenceEngine::new(test_config(models_dir));
17432 let tracker = engine.outcome_tracker.blocking_read();
17433 let profile = tracker
17434 .profile("qwen/qwen3-8b:q4_k_m")
17435 .expect("observed profile should remain present");
17436 assert!((profile.ema_quality - 0.21).abs() < 0.01);
17437 assert_eq!(profile.total_calls, 12);
17438
17439 unsafe {
17440 std::env::remove_var("CAR_BENCHMARK_PRIORS_PATH");
17441 }
17442 }
17443
17444 #[test]
17445 fn speech_runtime_package_spec_defaults_and_overrides() {
17446 let _env = ENV_MUTEX.blocking_lock();
17447 unsafe {
17448 std::env::remove_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC");
17449 }
17450 assert_eq!(speech_runtime_mlx_audio_spec(), "mlx-audio==0.4.2");
17451
17452 unsafe {
17453 std::env::set_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC", "mlx-audio==0.4.1");
17454 }
17455 assert_eq!(speech_runtime_mlx_audio_spec(), "mlx-audio==0.4.1");
17456
17457 unsafe {
17458 std::env::remove_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC");
17459 }
17460 }
17461
17462 #[test]
17463 fn speech_runtime_spacy_model_spec_defaults_and_overrides() {
17464 let _env = ENV_MUTEX.blocking_lock();
17465 unsafe {
17466 std::env::remove_var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC");
17467 }
17468 assert!(
17469 speech_runtime_spacy_model_spec().starts_with("en-core-web-sm @ https://github.com/")
17470 );
17471
17472 unsafe {
17473 std::env::set_var(
17474 "CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC",
17475 "en-core-web-sm==3.8.0",
17476 );
17477 }
17478 assert_eq!(speech_runtime_spacy_model_spec(), "en-core-web-sm==3.8.0");
17479
17480 unsafe {
17481 std::env::remove_var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC");
17482 }
17483 }
17484
17485 #[test]
17486 fn kokoro_runtime_fallback_defaults_on() {
17487 unsafe {
17488 std::env::remove_var("CAR_SPEECH_KOKORO_FALLBACK");
17489 }
17490 assert!(kokoro_runtime_fallback_enabled());
17491
17492 unsafe {
17493 std::env::set_var("CAR_SPEECH_KOKORO_FALLBACK", "false");
17494 }
17495 assert!(!kokoro_runtime_fallback_enabled());
17496
17497 unsafe {
17498 std::env::remove_var("CAR_SPEECH_KOKORO_FALLBACK");
17499 }
17500 }
17501
17502 #[test]
17503 fn preferred_local_tts_wins_over_builtin_rank() {
17504 let tmp = TempDir::new().unwrap();
17505 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17506 engine.set_speech_policy(SpeechPolicy {
17507 prefer_local: true,
17508 allow_remote_fallback: false,
17509 preferred_local_stt: None,
17510 preferred_local_tts: Some("Kokoro-82M-6bit".into()),
17511 preferred_remote_stt: None,
17512 preferred_remote_tts: None,
17513 });
17514
17515 let schema = engine
17516 .preferred_speech_schema(ModelCapability::TextToSpeech, true, false)
17517 .expect("preferred local TTS should resolve");
17518 #[cfg(not(target_os = "windows"))]
17525 assert_eq!(schema.name, "Kokoro-82M-6bit");
17526 #[cfg(target_os = "windows")]
17527 assert_eq!(schema.name, "Windows-Speech");
17528 }
17529
17530 #[test]
17531 fn preferred_discovered_vllm_mlx_model_wins_generate_routing() {
17532 let tmp = TempDir::new().unwrap();
17533 let mut config = test_config(tmp.path().join("models"));
17534 config.preferred_generation_model =
17535 Some("vllm-mlx/mlx-community_gemma-3n-E2B-it-lm-4bit".into());
17536 let mut engine = InferenceEngine::new(config);
17537 let schema = crate::vllm_mlx::to_model_schema(
17538 &crate::vllm_mlx::DiscoveredModel {
17539 id: "mlx-community/gemma-3n-E2B-it-lm-4bit".into(),
17540 owned_by: Some("mlx-community".into()),
17541 },
17542 "http://127.0.0.1:8001",
17543 );
17544 engine.register_model(schema);
17545
17546 let rt = tokio::runtime::Runtime::new().unwrap();
17547 let decision = rt.block_on(engine.route_adaptive("say hello in one sentence"));
17548 assert_eq!(
17549 decision.model_id,
17550 "vllm-mlx/mlx-community_gemma-3n-E2B-it-lm-4bit"
17551 );
17552 assert_eq!(decision.strategy, RoutingStrategy::Explicit);
17553 assert_eq!(decision.reason, "preferred generation model override");
17554 }
17555
17556 #[test]
17565 fn tools_request_on_non_tool_model_terminates_not_spins() {
17566 let tmp = TempDir::new().unwrap();
17567 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17568 let schema = crate::vllm_mlx::to_model_schema(
17570 &crate::vllm_mlx::DiscoveredModel {
17571 id: "test-org/embed-only-model".into(),
17572 owned_by: None,
17573 },
17574 "http://127.0.0.1:1", );
17576 let model_id = schema.id.clone();
17577 engine.register_model(schema);
17578
17579 let mut req = GenerateRequest {
17580 prompt: "call a tool".to_string(),
17581 model: Some(model_id),
17582 tools: Some(vec![serde_json::json!({
17583 "name": "noop", "description": "n", "parameters": {"type": "object"}
17584 })]),
17585 ..Default::default()
17586 };
17587 req.params.strict_model = true;
17588
17589 let rt = tokio::runtime::Runtime::new().unwrap();
17590 let out = rt.block_on(async {
17591 tokio::time::timeout(
17592 std::time::Duration::from_secs(10),
17593 engine.generate_tracked(req),
17594 )
17595 .await
17596 });
17597 let completed = out.expect("fallback loop must terminate, not spin");
17600 assert!(completed.is_err());
17601 }
17602
17603 fn fake_ready_speech_runtime(root: &Path) {
17614 managed_venv::seed_ready_venv(root);
17615 let runtime = SpeechRuntime::new(root.to_path_buf());
17616 for program in [&runtime.stt_program, &runtime.tts_program] {
17617 std::fs::create_dir_all(program.parent().expect("program has a parent")).unwrap();
17618 std::fs::write(program, b"").unwrap();
17619 }
17620 }
17621
17622 #[tokio::test]
17649 async fn prepare_speech_runtime_returns_the_root_doctor_reports() {
17650 let _env = ENV_MUTEX.lock().await;
17651 let tmp = TempDir::new().unwrap();
17652 let runtime_root = tmp.path().join("speech-runtime");
17653 fake_ready_speech_runtime(&runtime_root);
17654 unsafe {
17655 std::env::set_var("CAR_SPEECH_RUNTIME_DIR", &runtime_root);
17656 }
17657
17658 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17659 let root = engine
17660 .prepare_speech_runtime()
17661 .await
17662 .expect("prepare should succeed against a ready runtime");
17663 let health = engine.speech_health();
17664
17665 assert_eq!(
17666 root, health.runtime.root,
17667 "prepare returned a different root than doctor reports"
17668 );
17669 assert!(
17670 root.exists(),
17671 "prepare returned {} but it does not exist",
17672 root.display()
17673 );
17674 assert!(
17675 health.runtime.installed,
17676 "doctor should report a ready runtime as installed"
17677 );
17678 assert_eq!(
17680 engine
17681 .prepare_speech_runtime()
17682 .await
17683 .expect("second prepare should succeed"),
17684 root
17685 );
17686
17687 unsafe {
17688 std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
17689 }
17690 }
17691
17692 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
17702 #[tokio::test]
17703 async fn prepare_speech_runtime_degrades_when_bootstrap_fails() {
17704 let _env = ENV_MUTEX.lock().await;
17705 let tmp = TempDir::new().unwrap();
17706 let runtime_root = tmp.path().join("speech-runtime");
17707 assert!(!runtime_root.exists(), "precondition: root absent");
17708 unsafe {
17709 std::env::set_var("CAR_SPEECH_RUNTIME_DIR", &runtime_root);
17710 std::env::set_var("CAR_SPEECH_PYTHON", tmp.path().join("no-such-python"));
17711 }
17712
17713 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17714 let root = engine
17715 .prepare_speech_runtime()
17716 .await
17717 .expect("a failed bootstrap must degrade, not fail, on Apple Silicon");
17718 let health = engine.speech_health();
17719
17720 assert_eq!(root, health.runtime.root);
17721 assert!(
17722 root.exists(),
17723 "prepare returned {} but it does not exist",
17724 root.display()
17725 );
17726 assert!(
17727 !health.runtime.installed,
17728 "doctor must not claim a runtime the bootstrap never built"
17729 );
17730
17731 unsafe {
17732 std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
17733 std::env::remove_var("CAR_SPEECH_PYTHON");
17734 }
17735 }
17736
17737 #[cfg(target_os = "linux")]
17750 #[tokio::test]
17751 async fn a_runtime_that_cannot_be_built_no_longer_blocks_the_whisper_install() {
17752 let _env = ENV_MUTEX.lock().await;
17753 let tmp = TempDir::new().unwrap();
17754
17755 let cached = tmp
17759 .path()
17760 .join(".tokhn")
17761 .join("whisper")
17762 .join("ggml-large-v3-turbo-q5_0.bin");
17763 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
17764 std::fs::write(&cached, b"stand-in for the ggml weights").unwrap();
17765
17766 let previous_home = std::env::var_os("HOME");
17767 unsafe {
17768 std::env::set_var("HOME", tmp.path());
17769 std::env::set_var("CAR_SPEECH_RUNTIME_DIR", tmp.path().join("speech-runtime"));
17770 std::env::set_var("CAR_SPEECH_PYTHON", tmp.path().join("no-such-python"));
17774 }
17775
17776 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17777 let installed = engine.install_curated_speech().await;
17778
17779 let restore = || unsafe {
17780 match &previous_home {
17781 Some(value) => std::env::set_var("HOME", value),
17782 None => std::env::remove_var("HOME"),
17783 }
17784 std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
17785 std::env::remove_var("CAR_SPEECH_PYTHON");
17786 };
17787 let installed = match installed {
17788 Ok(installed) => installed,
17789 Err(error) => {
17790 restore();
17791 panic!("a runtime that cannot be built must not abort the install: {error}");
17792 }
17793 };
17794 let runtime_installed = engine.speech_health().runtime.installed;
17795 restore();
17796
17797 assert!(
17798 !runtime_installed,
17799 "precondition: the bootstrap must actually have failed, or this \
17800 test would pass without exercising anything"
17801 );
17802 let whisper = installed
17803 .iter()
17804 .find(|report| report.hf_repo == "ggerganov/whisper.cpp")
17805 .unwrap_or_else(|| {
17806 panic!(
17807 "the cross-platform whisper model must still be installed, got: {installed:?}"
17808 )
17809 });
17810 assert_eq!(whisper.snapshot_path, cached);
17811 assert!(
17812 installed
17813 .iter()
17814 .all(|report| report.hf_repo == "ggerganov/whisper.cpp"),
17815 "MLX weights are Apple-only and must not be pulled here — over a \
17816 gigabyte of them, for models this host can never load; got: {installed:?}"
17817 );
17818 }
17819
17820 #[test]
17828 fn alias_resolves_to_canonical_id_for_outcome_keying() {
17829 let tmp = TempDir::new().unwrap();
17830 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17831
17832 let cases = [
17834 ("claude-sonnet-4-6", "anthropic/claude-sonnet-4-6:latest"),
17835 ("gpt-5.4", "openai/gpt-5.4:latest"),
17836 ("gemini-2.5-flash", "google/gemini-2.5-flash:latest"),
17837 ];
17838 for (alias, canonical) in cases {
17839 let resolved = engine
17842 .unified_registry
17843 .get(alias)
17844 .or_else(|| engine.unified_registry.find_by_name(alias))
17845 .map(|s| s.id.clone())
17846 .unwrap_or_else(|| alias.to_string());
17847 assert_eq!(
17848 resolved, canonical,
17849 "alias `{alias}` must resolve to canonical `{canonical}` for outcome keying, got `{resolved}`"
17850 );
17851 assert_ne!(
17852 resolved, alias,
17853 "alias `{alias}` must NOT be recorded raw — that is the fragmentation bug"
17854 );
17855 }
17856 }
17857
17858 #[test]
17863 fn inference_result_serializes_with_full_shape() {
17864 use crate::tasks::generate::ToolCall;
17865 use std::collections::HashMap;
17866
17867 let mut args = HashMap::new();
17868 args.insert("path".to_string(), serde_json::json!("README.md"));
17869
17870 let result = InferenceResult {
17871 text: String::new(),
17872 bounding_boxes: Vec::new(),
17873 tool_calls: vec![ToolCall {
17874 id: None,
17875 name: "read_file".into(),
17876 arguments: args,
17877 }],
17878 trace_id: "trace-abc".into(),
17879 model_used: "test-model".into(),
17880 model_identity: InferenceModelIdentity {
17881 requested_model_id: Some("test/model:1".into()),
17882 resolved_model_id: "test/model:1".into(),
17883 row_digest: "a".repeat(64),
17884 catalog_revision: "b".repeat(64),
17885 },
17886 latency_ms: 1234,
17887 time_to_first_token_ms: Some(180),
17888 usage: Some(TokenUsage {
17889 prompt_tokens: 100,
17890 completion_tokens: 50,
17891 total_tokens: 150,
17892 context_window: 8192,
17893 ..Default::default()
17894 }),
17895 provider_output_items: Vec::new(),
17896 thinking: Vec::new(),
17897 stop_reason: Some("tool_use".into()),
17898 auth_fallback_from: None,
17899 local_last_resort: false,
17900 fallback_from: Vec::new(),
17901 };
17902
17903 let json = serde_json::to_value(&result).expect("serialize");
17904
17905 assert_eq!(json["stop_reason"].as_str(), Some("tool_use"));
17907
17908 assert_eq!(json["text"].as_str(), Some(""));
17910 assert_eq!(json["trace_id"].as_str(), Some("trace-abc"));
17911 assert_eq!(json["model_used"].as_str(), Some("test-model"));
17912 assert_eq!(json["requested_model_id"].as_str(), Some("test/model:1"));
17913 assert_eq!(json["resolved_model_id"].as_str(), Some("test/model:1"));
17914 assert_eq!(json["row_digest"].as_str().unwrap(), "a".repeat(64));
17915 assert_eq!(json["catalog_revision"].as_str().unwrap(), "b".repeat(64));
17916 assert_eq!(json["latency_ms"].as_u64(), Some(1234));
17917
17918 let tool_calls = json["tool_calls"].as_array().expect("tool_calls array");
17920 assert_eq!(tool_calls.len(), 1);
17921 assert_eq!(tool_calls[0]["name"].as_str(), Some("read_file"));
17922 assert_eq!(
17923 tool_calls[0]["arguments"]["path"].as_str(),
17924 Some("README.md")
17925 );
17926
17927 let usage = &json["usage"];
17929 assert_eq!(usage["prompt_tokens"].as_u64(), Some(100));
17930 assert_eq!(usage["completion_tokens"].as_u64(), Some(50));
17931 assert_eq!(usage["total_tokens"].as_u64(), Some(150));
17932 assert_eq!(usage["context_window"].as_u64(), Some(8192));
17933
17934 assert_eq!(json["time_to_first_token_ms"].as_u64(), Some(180));
17936 }
17937
17938 #[test]
17944 fn inference_result_top_level_keys_are_locked() {
17945 use std::collections::BTreeSet;
17946
17947 let result = InferenceResult {
17948 text: "anything".into(),
17949 bounding_boxes: Vec::new(),
17950 tool_calls: vec![],
17951 trace_id: "t".into(),
17952 model_used: "m".into(),
17953 model_identity: InferenceModelIdentity::default(),
17954 latency_ms: 0,
17955 time_to_first_token_ms: None,
17956 usage: None,
17957 provider_output_items: Vec::new(),
17958 thinking: Vec::new(),
17959 stop_reason: None,
17960 auth_fallback_from: None,
17961 local_last_resort: false,
17962 fallback_from: Vec::new(),
17963 };
17964
17965 let json = serde_json::to_value(&result).expect("serialize");
17966 let keys: BTreeSet<&str> = json
17967 .as_object()
17968 .expect("top-level object")
17969 .keys()
17970 .map(String::as_str)
17971 .collect();
17972
17973 let expected: BTreeSet<&str> = [
17974 "text",
17975 "tool_calls",
17976 "trace_id",
17977 "model_used",
17978 "requested_model_id",
17979 "resolved_model_id",
17980 "row_digest",
17981 "catalog_revision",
17982 "latency_ms",
17983 "time_to_first_token_ms",
17984 "usage",
17985 "stop_reason",
17986 ]
17987 .into_iter()
17988 .collect();
17989
17990 assert_eq!(
17991 keys, expected,
17992 "infer response top-level keys drifted -- update both the test \
17993 and the WebSocket protocol documentation if this is intentional"
17994 );
17995
17996 for key in &keys {
17998 assert!(
17999 !key.chars().any(|c| c.is_uppercase()) && !key.contains('-'),
18000 "key '{}' is not snake_case",
18001 key
18002 );
18003 }
18004 }
18005
18006 #[test]
18010 fn inference_result_serializes_plain_text_response() {
18011 let result = InferenceResult {
18012 text: "hello world".into(),
18013 bounding_boxes: Vec::new(),
18014 tool_calls: vec![],
18015 trace_id: "trace-xyz".into(),
18016 model_used: "test-model".into(),
18017 model_identity: InferenceModelIdentity::default(),
18018 latency_ms: 42,
18019 time_to_first_token_ms: None,
18020 usage: None,
18021 provider_output_items: Vec::new(),
18022 thinking: Vec::new(),
18023 stop_reason: None,
18024 auth_fallback_from: None,
18025 local_last_resort: false,
18026 fallback_from: Vec::new(),
18027 };
18028
18029 let json = serde_json::to_value(&result).expect("serialize");
18030 assert_eq!(json["text"], "hello world");
18031 assert!(json["stop_reason"].is_null());
18033 assert!(json["tool_calls"].is_array());
18034 assert_eq!(json["tool_calls"].as_array().unwrap().len(), 0);
18035 assert_eq!(json["model_used"], "test-model");
18036 assert!(json["usage"].is_null());
18037 assert!(json["time_to_first_token_ms"].is_null());
18040 }
18041
18042 #[test]
18043 fn append_assistant_history_preserves_responses_items_in_provider_order() {
18044 let reasoning = serde_json::json!({
18045 "type": "reasoning",
18046 "id": "rs_history",
18047 "status": "completed",
18048 "summary": [{"type": "summary_text", "text": "safe"}],
18049 "encrypted_content": "opaque",
18050 });
18051 let result: InferenceResult = serde_json::from_value(serde_json::json!({
18052 "text": "calling",
18053 "tool_calls": [{
18054 "id": "call_1",
18055 "name": "read_file",
18056 "arguments": {"path": "README.md"}
18057 }],
18058 "trace_id": "trace",
18059 "model_used": "gateway-alias",
18060 "resolved_model_id": "openrouter/anthropic/claude-sonnet-4.5",
18061 "local_last_resort": true,
18062 "latency_ms": 1,
18063 "provider_output_items": [reasoning.clone()],
18064 }))
18065 .unwrap();
18066 let mut history = vec![crate::tasks::generate::Message::User {
18067 content: "inspect".into(),
18068 }];
18069
18070 result.append_assistant_history(&mut history, result.tool_calls.clone());
18071
18072 assert!(matches!(
18073 &history[1],
18074 crate::tasks::generate::Message::ProviderOutputItems { protocol, items }
18075 if protocol == crate::protocol::OPENAI_RESPONSES_PROTOCOL
18076 && items == &vec![reasoning]
18077 ));
18078 assert!(matches!(
18079 &history[2],
18080 crate::tasks::generate::Message::Assistant {
18081 content,
18082 tool_calls,
18083 model_id,
18084 local_last_resort,
18085 ..
18086 } if content == "calling"
18087 && tool_calls[0].id.as_deref() == Some("call_1")
18088 && model_id.as_deref() == Some("openrouter/anthropic/claude-sonnet-4.5")
18089 && *local_last_resort
18090 ));
18091 }
18092
18093 #[test]
18094 fn append_assistant_history_leaves_personal_chat_history_unchanged() {
18095 let result: InferenceResult = serde_json::from_value(serde_json::json!({
18096 "text": "plain",
18097 "tool_calls": [],
18098 "trace_id": "trace",
18099 "model_used": "openrouter/openai/gpt-4.1-mini",
18100 "latency_ms": 1,
18101 }))
18102 .unwrap();
18103 let mut history = Vec::new();
18104
18105 result.append_assistant_history(&mut history, Vec::new());
18106
18107 assert_eq!(history.len(), 1);
18108 assert!(matches!(
18109 &history[0],
18110 crate::tasks::generate::Message::Assistant { content, .. } if content == "plain"
18111 ));
18112 }
18113
18114 #[test]
18126 fn generate_request_deserializes_intent_field_from_json_rpc_params() {
18127 use crate::intent::TaskHint;
18128 use crate::schema::ModelCapability;
18129
18130 let params = serde_json::json!({
18133 "prompt": "summarize this email",
18134 "intent": {
18135 "task": "chat",
18136 "prefer_local": true,
18137 "require": ["tool_use"],
18138 },
18139 });
18140
18141 let req: GenerateRequest =
18142 serde_json::from_value(params).expect("GenerateRequest deserialize");
18143
18144 let intent = req.intent.as_ref().expect("intent field deserialized");
18145 assert_eq!(intent.task, Some(TaskHint::Chat));
18146 assert!(intent.prefer_local);
18147 assert_eq!(intent.require, vec![ModelCapability::ToolUse]);
18148
18149 let back: serde_json::Value =
18153 serde_json::to_value(&req).expect("re-serialize GenerateRequest");
18154 assert_eq!(back["intent"]["task"], "chat");
18155 assert_eq!(back["intent"]["prefer_local"], true);
18156 assert_eq!(back["intent"]["require"][0], "tool_use");
18157
18158 let default_req: GenerateRequest = serde_json::from_value(serde_json::json!({
18163 "prompt": "x",
18164 "intent": {},
18165 }))
18166 .unwrap();
18167 let default_intent = default_req.intent.expect("present but empty");
18168 assert_eq!(default_intent.task, None);
18169 assert!(!default_intent.prefer_local);
18170 assert!(default_intent.require.is_empty());
18171
18172 let no_intent: GenerateRequest =
18175 serde_json::from_value(serde_json::json!({"prompt": "x"})).unwrap();
18176 assert!(no_intent.intent.is_none());
18177 }
18178
18179 #[test]
18180 fn rerank_prompt_matches_upstream_template_shape() {
18181 let p = rerank_prompt(
18182 "retrieve relevant passages",
18183 "who runs the treasury?",
18184 "doc x",
18185 );
18186 assert!(p.contains("<|im_start|>system"));
18187 assert!(p.contains("Note that the answer can only be \"yes\" or \"no\"."));
18188 assert!(p.contains("<|im_start|>user\n<Instruct>: retrieve relevant passages"));
18189 assert!(p.contains("<Query>: who runs the treasury?"));
18190 assert!(p.contains("<Document>: doc x<|im_end|>"));
18191 assert!(p.contains("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
18192 }
18193
18194 #[test]
18195 fn rerank_score_yes_and_no_exactly() {
18196 assert_eq!(score_from_rerank_output("yes", "m"), 1.0);
18197 assert_eq!(score_from_rerank_output("no", "m"), 0.0);
18198 }
18199
18200 #[test]
18201 fn rerank_score_handles_case_leading_space_and_chat_sentinels() {
18202 assert_eq!(score_from_rerank_output(" Yes", "m"), 1.0);
18205 assert_eq!(score_from_rerank_output("\nno.", "m"), 0.0);
18206 assert_eq!(score_from_rerank_output("<|im_end|>yes", "m"), 1.0);
18207 }
18208
18209 #[test]
18210 fn rerank_score_scans_up_to_three_tokens() {
18211 assert_eq!(score_from_rerank_output("_bos_ yes", "m"), 1.0);
18214 }
18215
18216 #[test]
18217 fn rerank_score_unexpected_is_neutral() {
18218 assert_eq!(score_from_rerank_output("maybe", "m"), 0.5);
18221 assert_eq!(score_from_rerank_output("", "m"), 0.5);
18222 }
18223
18224 #[tokio::test]
18225 async fn pull_reuses_a_valid_directory_receipt_and_reports_removability() {
18226 let root = tempfile::tempdir().unwrap();
18227 let models_dir = root.path().join("models");
18228 std::fs::create_dir_all(&models_dir).unwrap();
18229 let engine = InferenceEngine::new(InferenceConfig {
18230 state_root: root.path().join("state"),
18231 models_dir: models_dir.clone(),
18232 ..InferenceConfig::default()
18233 });
18234 let schema = engine
18235 .unified_registry
18236 .all()
18237 .find(|schema| matches!(schema.source, ModelSource::Local { .. }))
18238 .unwrap()
18239 .clone();
18240 let managed = models_dir.join(&schema.name);
18241 std::fs::create_dir_all(&managed).unwrap();
18242 std::fs::write(managed.join("model.gguf"), b"owned").unwrap();
18243 std::fs::write(managed.join("tokenizer.json"), b"{}").unwrap();
18244 engine
18245 .model_management
18246 .record_managed_artifact(
18247 &schema.id,
18248 model_source_identity(&schema),
18249 None,
18250 1,
18251 false,
18252 managed.clone(),
18253 )
18254 .unwrap();
18255 let row = engine
18256 .list_models_unified()
18257 .into_iter()
18258 .find(|row| row.id == schema.id)
18259 .unwrap();
18260 if model_management::directory_removal_supported() {
18261 assert!(engine.model_management.can_remove(&schema.id).unwrap());
18262 assert!(row.can_remove);
18263 assert_eq!(row.management_evidence.as_deref(), Some("install_receipt"));
18264 } else {
18265 assert!(!engine.model_management.can_remove(&schema.id).unwrap());
18266 assert!(!row.can_remove);
18267 assert_eq!(
18268 row.management_evidence.as_deref(),
18269 Some("install_receipt_directory_cleanup_unsupported")
18270 );
18271 }
18272
18273 let reused = engine.pull_model(&schema.id).await.unwrap();
18274 assert_eq!(reused, managed);
18275 }
18276
18277 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
18278 #[test]
18279 fn kokoro_cache_is_explicitly_state_root_scoped_and_does_not_pin_runtime() {
18280 let first_root = tempfile::tempdir().unwrap();
18281 let second_root = tempfile::tempdir().unwrap();
18282 let first = scoped_kokoro_backend_cache(first_root.path());
18283 let second = scoped_kokoro_backend_cache(second_root.path());
18284 assert!(!Arc::ptr_eq(first.cache(), second.cache()));
18285 assert!(!Arc::ptr_eq(first.admission(), second.admission()));
18286
18287 let runtime = Arc::downgrade(&first._runtime);
18288 drop(first);
18289 assert!(
18290 runtime.upgrade().is_none(),
18291 "an unused Kokoro accessor must not process-pin the scoped runtime"
18292 );
18293 }
18294}
18295
18296#[cfg(test)]
18297mod response_format_support_tests {
18298 use super::*;
18299
18300 #[test]
18304 fn rejection_reason_tracks_the_protocol_handler() {
18305 let engine = InferenceEngine::new(Default::default());
18306 let rf = crate::tasks::generate::ResponseFormat::JsonObject;
18307 let models = engine.list_models_unified();
18308 let anthropic = models
18309 .iter()
18310 .find(|m| m.provider.eq_ignore_ascii_case("anthropic"));
18311 if let Some(m) = anthropic {
18312 let reason = engine
18313 .response_format_rejection_reason(&m.id, &rf)
18314 .expect("the Anthropic protocol rejects response_format");
18315 assert!(reason.contains("protocol rejects"), "{reason}");
18316 }
18317 let openrouter = models
18318 .iter()
18319 .find(|m| m.provider.eq_ignore_ascii_case("openrouter"));
18320 if let Some(m) = openrouter {
18321 assert_eq!(
18322 engine.response_format_rejection_reason(&m.id, &rf),
18323 None,
18324 "OpenRouter forwards the format upstream"
18325 );
18326 }
18327 assert!(
18328 anthropic.is_some() || openrouter.is_some(),
18329 "the builtin catalog should list at least one of the two providers this pins"
18330 );
18331 assert_eq!(
18332 engine.response_format_rejection_reason("no/such-model", &rf),
18333 None,
18334 "an unknown model is not a rejection"
18335 );
18336 }
18337}