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(Debug, Clone, PartialEq, Eq)]
189pub struct InferenceRetryProgress {
190 pub model: String,
192 pub attempt: u32,
194 pub reason: &'static str,
196 pub backoff_ms: u64,
198}
199
200#[derive(Error, Debug)]
201pub enum InferenceError {
202 #[error("model not found: {0}")]
203 ModelNotFound(String),
204
205 #[error("no eligible model remains after required exclusions: {excluded_models}")]
209 NoEligibleModel { excluded_models: String },
210
211 #[error("model download failed: {0}")]
212 DownloadFailed(String),
213
214 #[error("inference failed: {0}")]
215 InferenceFailed(String),
216
217 #[error("catalog precondition mismatch: {detail}")]
222 CatalogPreconditionMismatch { detail: String },
223
224 #[error("controlled inference termination confirmed")]
229 ControlledTermination,
230
231 #[error(transparent)]
232 ModelManagement(#[from] model_management::ModelManagementError),
233
234 #[error("{recovery}")]
237 LocalResourceBlocked {
238 preflight: resource_policy::LocalLoadPreflight,
239 recovery: String,
240 },
241
242 #[error("transient remote failure after retries (status={status:?}): {message}")]
251 Transient {
252 status: Option<u16>,
253 message: String,
254 },
255
256 #[error("deadline exceeded: the caller's {applied_ms} ms infer deadline elapsed after {elapsed_ms} ms; last attempt: {last_error}")]
264 DeadlineExceeded {
265 applied_ms: u64,
266 elapsed_ms: u64,
267 last_error: String,
268 },
269
270 #[error("mode {mode} not implemented on backend {backend}: {reason}")]
275 UnsupportedMode {
276 mode: &'static str,
277 backend: &'static str,
278 reason: &'static str,
279 },
280
281 #[error("{provider} account rejected the request (HTTP {status}): {message}")]
296 ProviderAccount {
297 provider: String,
298 status: u16,
299 message: String,
300 },
301
302 #[error("inference failed: {message}")]
311 ProviderKeyMissing {
312 provider: String,
313 model: String,
314 env_vars: Vec<String>,
317 message: String,
320 },
321
322 #[error("no credential for proprietary provider '{provider}' (model {model}): {detail}")]
339 CredentialUnavailable {
340 provider: String,
341 model: String,
342 reason: CredentialFailure,
344 detail: String,
346 },
347
348 #[error("{provider} account has no workspace yet: {detail}")]
365 WorkspaceRequired {
366 provider: String,
368 detail: String,
370 },
371
372 #[error("{provider} refused this request on content grounds{}{}: {message}",
392 .kind.as_deref().map(|k| format!(" (type={k}")).unwrap_or_default(),
393 .code.as_deref().map(|c| format!(", code={c})")).unwrap_or_default())]
394 ContentRefused {
395 provider: String,
396 kind: Option<String>,
398 code: Option<String>,
399 message: String,
400 },
401
402 #[error("{provider} gateway has no upstream configured for '{namespace}' (HTTP {status}): {message}")]
420 GatewayUnconfigured {
421 provider: String,
422 namespace: String,
423 status: u16,
424 message: String,
425 },
426
427 #[error("tokenization error: {0}")]
428 TokenizationError(String),
429
430 #[error("device error: {0}")]
431 DeviceError(String),
432
433 #[error("io error: {0}")]
434 Io(#[from] std::io::Error),
435}
436
437impl From<resource_policy::LocalAdmissionError> for InferenceError {
438 fn from(error: resource_policy::LocalAdmissionError) -> Self {
439 let recovery = match error.preflight.verdict {
440 resource_policy::LocalLoadVerdict::DisabledByPolicy => {
441 "Local model loading is disabled by the 0 GB allocation. Increase Local Models RAM in Settings, or choose a remote model. Downloads remain available.".to_string()
442 }
443 resource_policy::LocalLoadVerdict::ExceedsConfiguredCeiling => format!(
444 "This model needs about {} MB for this request, beyond the configured {} MB local-model allocation. Increase the allocation or choose a smaller model.",
445 error.preflight.estimated_incremental_mb,
446 error.preflight.configured_ceiling_mb
447 ),
448 resource_policy::LocalLoadVerdict::InsufficientLiveMemory => format!(
449 "Not enough memory is free to start this model while preserving CAR's {} MB emergency reserve. Close memory-heavy apps or choose a smaller model.",
450 error.preflight.emergency_reserve_mb
451 ),
452 resource_policy::LocalLoadVerdict::LiveMemoryUnknown => {
453 "CAR could not measure live memory. The static allocation fits, but current safety is unknown.".to_string()
454 }
455 resource_policy::LocalLoadVerdict::ModelMaintenance => {
456 "This local model is being removed or maintained. Wait for that operation to finish, then retry.".to_string()
457 }
458 resource_policy::LocalLoadVerdict::PendingTeardown => {
459 "CAR is still confirming that the previous local model process exited. Wait for teardown to finish, then retry.".to_string()
460 }
461 resource_policy::LocalLoadVerdict::Allowed => error.to_string(),
462 };
463 Self::LocalResourceBlocked {
464 preflight: error.preflight,
465 recovery,
466 }
467 }
468}
469
470fn error_counts_against_circuit_breaker(e: &InferenceError) -> bool {
504 !matches!(
505 e,
506 InferenceError::UnsupportedMode { .. }
507 | InferenceError::ProviderAccount { .. }
508 | InferenceError::ProviderKeyMissing { .. }
509 | InferenceError::CredentialUnavailable { .. }
510 | InferenceError::WorkspaceRequired { .. }
511 | InferenceError::GatewayUnconfigured { .. }
512 | InferenceError::ContentRefused { .. }
518 | InferenceError::CatalogPreconditionMismatch { .. }
519 | InferenceError::ControlledTermination
520 )
521}
522
523fn record_dispatch_failure(tracker: &mut OutcomeTracker, trace_id: &str, error: &InferenceError) {
529 match error {
530 InferenceError::UnsupportedMode { .. } | InferenceError::ContentRefused { .. } => {
531 tracker.record_capability_rejection(trace_id, &error.to_string())
532 }
533 InferenceError::ProviderAccount { .. }
534 | InferenceError::ProviderKeyMissing { .. }
535 | InferenceError::CredentialUnavailable { .. }
536 | InferenceError::WorkspaceRequired { .. }
537 | InferenceError::GatewayUnconfigured { .. } => {
538 tracker.record_account_rejection(trace_id, &error.to_string())
539 }
540 _ => tracker.record_failure(trace_id, &error.to_string()),
541 }
542}
543
544fn error_ends_fallback_chain(e: &InferenceError) -> bool {
566 matches!(
567 e,
568 InferenceError::ContentRefused { .. }
569 | InferenceError::CatalogPreconditionMismatch { .. }
570 | InferenceError::ControlledTermination
571 | InferenceError::WorkspaceRequired { .. }
577 )
578}
579
580fn apply_exhaustion_recovery_hint(underlying: InferenceError) -> InferenceError {
591 if matches!(
592 underlying,
593 InferenceError::ContentRefused { .. }
594 | InferenceError::CatalogPreconditionMismatch { .. }
595 | InferenceError::ControlledTermination
596 | InferenceError::WorkspaceRequired { .. }
601 ) {
602 return underlying;
603 }
604 let underlying_str = underlying.to_string();
605 match no_backend_recovery_hint(&underlying_str)
606 .or_else(|| auth_expired_recovery_hint(&underlying_str))
607 {
608 Some(msg) => InferenceError::InferenceFailed(msg),
609 None => underlying,
610 }
611}
612
613const AUTH_LOGIN_MARKER: &str = "auth login";
614const AUTH_STORE_UNREADABLE_MARKER: &str = "credential store unreadable";
615const AUTH_ENV_MISSING_MARKER: &str = "credential environment variable missing";
616
617const AUTH_FAILURE_MESSAGE_MARKERS: &[&str] = &[
622 "no credential for proprietary",
623 AUTH_LOGIN_MARKER,
624 "session has expired",
625 "cannot read parslee credentials",
626 AUTH_STORE_UNREADABLE_MARKER,
627 AUTH_ENV_MISSING_MARKER,
628 "credential was rejected",
634 "repair its provider login",
635];
636
637#[derive(Debug, Clone, PartialEq, Eq)]
647struct RouteCredentialFailure {
648 summary: String,
649 source_error: String,
650}
651
652fn parslee_signed_out_route_failure() -> RouteCredentialFailure {
653 let summary = "Parslee login is absent — run `car auth login` before retrying".to_string();
654 RouteCredentialFailure {
655 source_error: summary.clone(),
656 summary,
657 }
658}
659
660fn route_credential_failure(
661 candidate: &str,
662 error: &InferenceError,
663 promote_missing_credential: bool,
664) -> Option<String> {
665 match error {
666 InferenceError::CredentialUnavailable {
667 provider,
668 reason,
669 detail,
670 ..
671 } => {
672 let provider_name = if provider.eq_ignore_ascii_case("parslee") {
673 "Parslee".to_string()
674 } else {
675 provider.clone()
676 };
677 let summary = match reason {
678 CredentialFailure::Expired { .. } => format!(
679 "{provider_name} login expired for `{candidate}` — run `car auth login`"
680 ),
681 CredentialFailure::SignedOut => format!(
682 "{provider_name} login is absent for `{candidate}` — run `car auth login`"
683 ),
684 CredentialFailure::StoreUnreadable => format!(
685 "{provider_name} {AUTH_STORE_UNREADABLE_MARKER} for `{candidate}` — unlock the credential store, then retry"
686 ),
687 CredentialFailure::EnvVarMissing { .. } if !promote_missing_credential => {
688 return None;
689 }
690 CredentialFailure::EnvVarMissing { env_var } => format!(
691 "{provider_name} {AUTH_ENV_MISSING_MARKER}: `{env_var}` for explicitly requested `{candidate}` — {detail}"
692 ),
693 CredentialFailure::RaceRetryable => return None,
696 };
697 Some(summary)
698 }
699 InferenceError::ProviderAccount {
700 provider, status, ..
701 } if matches!(*status, 401 | 403) => {
702 if provider.eq_ignore_ascii_case("parslee") {
703 Some(format!(
704 "Parslee login expired or was rejected for `{candidate}` — run `car auth login`"
705 ))
706 } else {
707 Some(format!(
708 "{provider} credential was rejected for `{candidate}` (HTTP {status})"
709 ))
710 }
711 }
712 _ => {
713 let rendered = error.to_string();
714 let lower = rendered.to_ascii_lowercase();
715 if is_auth_rejection_message(&rendered) {
716 if candidate
717 .split_once('/')
718 .is_some_and(|(provider, _)| provider.eq_ignore_ascii_case("parslee"))
719 {
720 Some(format!(
721 "Parslee login expired or was rejected for `{candidate}` — run `car auth login`"
722 ))
723 } else {
724 Some(format!(
725 "credential expired or was rejected for `{candidate}` — repair its provider login"
726 ))
727 }
728 } else if lower.contains(AUTH_STORE_UNREADABLE_MARKER)
729 || (promote_missing_credential && lower.contains("keychain lookup failed"))
730 {
731 Some(format!(
732 "{AUTH_STORE_UNREADABLE_MARKER} for `{candidate}` — unlock the credential store, then retry"
733 ))
734 } else {
735 None
736 }
737 }
738 }
739}
740
741fn record_route_credential_failure(
742 slot: &mut Option<RouteCredentialFailure>,
743 candidate: &str,
744 error: &InferenceError,
745 promote_missing_credential: bool,
746) {
747 if let Some(summary) = route_credential_failure(candidate, error, promote_missing_credential) {
748 *slot = Some(RouteCredentialFailure {
753 summary,
754 source_error: error.to_string(),
755 });
756 }
757}
758
759fn chain_includes_parslee_route<'a>(
764 mut resolve: impl FnMut(&str) -> Option<&'a ModelSchema>,
765 chain: &[String],
766) -> bool {
767 chain.iter().any(|candidate| {
768 resolve(candidate).is_some_and(|schema| schema.provider.eq_ignore_ascii_case("parslee"))
769 })
770}
771
772fn apply_route_failure_context(
792 underlying: InferenceError,
793 credential: Option<&RouteCredentialFailure>,
794) -> InferenceError {
795 if matches!(
796 underlying,
797 InferenceError::ContentRefused { .. }
798 | InferenceError::CatalogPreconditionMismatch { .. }
799 | InferenceError::ControlledTermination
800 | InferenceError::WorkspaceRequired { .. }
808 ) {
809 return underlying;
810 }
811 let Some(credential) = credential else {
812 return apply_exhaustion_recovery_hint(underlying);
813 };
814 let rendered = underlying.to_string();
815 let summary = credential.summary.as_str();
816 let setup_hint = no_backend_recovery_hint(&rendered);
817 let connective = if rendered == credential.source_error {
818 "provider detail"
819 } else {
820 "fallback then failed"
821 };
822 let augment = |field: String| match &setup_hint {
823 Some(hint) => format!("{summary}; {hint}"),
825 None => format!("{summary}; {connective}: {field}"),
826 };
827 match underlying {
828 InferenceError::InferenceFailed(message) => {
829 InferenceError::InferenceFailed(augment(message))
830 }
831 InferenceError::ProviderKeyMissing {
832 provider,
833 model,
834 env_vars,
835 message,
836 } => InferenceError::ProviderKeyMissing {
837 provider,
838 model,
839 env_vars,
840 message: augment(message),
841 },
842 InferenceError::Transient { status, message } => InferenceError::Transient {
843 status,
844 message: augment(message),
845 },
846 InferenceError::ProviderAccount {
847 provider,
848 status,
849 message,
850 } => InferenceError::ProviderAccount {
851 provider,
852 status,
853 message: augment(message),
854 },
855 InferenceError::CredentialUnavailable {
856 provider,
857 model,
858 reason,
859 detail,
860 } => InferenceError::CredentialUnavailable {
861 provider,
862 model,
863 reason,
864 detail: augment(detail),
865 },
866 InferenceError::GatewayUnconfigured {
867 provider,
868 namespace,
869 status,
870 message,
871 } => InferenceError::GatewayUnconfigured {
872 provider,
873 namespace,
874 status,
875 message: augment(message),
876 },
877 InferenceError::LocalResourceBlocked {
878 preflight,
879 recovery,
880 } => InferenceError::LocalResourceBlocked {
881 preflight,
882 recovery: augment(recovery),
883 },
884 _ => InferenceError::InferenceFailed(augment(rendered)),
887 }
888}
889
890#[derive(Debug, Clone, PartialEq, Eq)]
899pub enum CredentialFailure {
900 Expired { expires_at: u64 },
907 SignedOut,
910 StoreUnreadable,
914 EnvVarMissing { env_var: String },
916 RaceRetryable,
919}
920
921#[derive(Debug, Clone, Copy, PartialEq, Eq)]
923pub enum Device {
924 Cpu,
925 Metal,
926 Cuda(usize), }
928
929impl Device {
930 pub fn auto() -> Self {
938 #[cfg(all(target_os = "macos", feature = "metal"))]
939 {
940 return Device::Metal;
941 }
942 #[cfg(all(
943 any(target_os = "linux", target_os = "windows"),
944 target_arch = "x86_64",
945 not(car_skip_cuda)
946 ))]
947 {
948 return Device::Cuda(0);
949 }
950 #[cfg(not(any(
951 all(target_os = "macos", feature = "metal"),
952 all(
953 any(target_os = "linux", target_os = "windows"),
954 target_arch = "x86_64",
955 not(car_skip_cuda)
956 )
957 )))]
958 {
959 Device::Cpu
960 }
961 }
962}
963
964#[derive(Debug, Clone)]
966pub struct InferenceConfig {
967 pub models_dir: std::path::PathBuf,
973 pub state_root: std::path::PathBuf,
982 pub device: Option<Device>,
984 pub generation_model: String,
986 pub preferred_generation_model: Option<String>,
988 pub embedding_model: String,
990 pub preferred_embedding_model: Option<String>,
992 pub classification_model: String,
994 pub preferred_classification_model: Option<String>,
996}
997
998impl Default for InferenceConfig {
999 fn default() -> Self {
1000 let models_dir = default_models_dir();
1017
1018 let hw = HardwareInfo::detect();
1019
1020 Self {
1021 models_dir,
1022 state_root: car_home::root_or_relative(),
1023 device: None,
1024 generation_model: hw.recommended_model,
1025 preferred_generation_model: None,
1026 embedding_model: "Qwen3-Embedding-0.6B".to_string(),
1027 preferred_embedding_model: None,
1028 classification_model: "Qwen3-0.6B".to_string(),
1029 preferred_classification_model: None,
1030 }
1031 }
1032}
1033
1034impl InferenceConfig {
1035 pub fn state_models_dir(&self) -> std::path::PathBuf {
1044 self.state_root.join("models")
1045 }
1046}
1047
1048pub fn default_models_dir() -> std::path::PathBuf {
1055 dirs_next()
1056 .unwrap_or_else(|| std::path::PathBuf::from("."))
1057 .join(".car")
1058 .join("models")
1059}
1060
1061fn dirs_next() -> Option<std::path::PathBuf> {
1062 std::env::var_os("HOME")
1066 .or_else(|| std::env::var_os("USERPROFILE"))
1067 .map(std::path::PathBuf::from)
1068}
1069
1070fn model_source_identity(schema: &ModelSchema) -> &str {
1071 match &schema.source {
1072 ModelSource::Local { hf_repo, .. }
1073 | ModelSource::Mlx { hf_repo, .. }
1074 | ModelSource::ManagedVllmMlx { hf_repo, .. } => hf_repo,
1075 ModelSource::WhisperCpp { model } | ModelSource::CodexCli { model } => model,
1076 _ => &schema.id,
1077 }
1078}
1079
1080#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
1082pub struct TokenUsage {
1083 pub prompt_tokens: u64,
1093 pub completion_tokens: u64,
1095 pub total_tokens: u64,
1097 pub context_window: u64,
1099 #[serde(default)]
1104 pub cache_read_input_tokens: u64,
1105 #[serde(default)]
1110 pub cache_creation_input_tokens: u64,
1111}
1112
1113#[derive(
1115 Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq, schemars::JsonSchema,
1116)]
1117pub struct InferenceModelIdentity {
1118 #[serde(default)]
1121 pub requested_model_id: Option<String>,
1122 #[serde(default)]
1124 pub resolved_model_id: String,
1125 #[serde(default)]
1127 pub row_digest: String,
1128 #[serde(default)]
1130 pub catalog_revision: String,
1131}
1132
1133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
1135pub struct InferenceResult {
1136 pub text: String,
1138 pub tool_calls: Vec<crate::tasks::generate::ToolCall>,
1140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1147 pub bounding_boxes: Vec<crate::tasks::grounding::BoundingBox>,
1148 pub trace_id: String,
1150 pub model_used: String,
1153 #[serde(flatten)]
1156 pub model_identity: InferenceModelIdentity,
1157 pub latency_ms: u64,
1159 #[serde(default)]
1171 pub time_to_first_token_ms: Option<u64>,
1172 pub usage: Option<TokenUsage>,
1191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1204 pub provider_output_items: Vec<serde_json::Value>,
1205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1213 pub thinking: Vec<crate::tasks::generate::ThinkingBlock>,
1214 #[serde(default)]
1230 pub stop_reason: Option<String>,
1231 #[serde(default, skip_serializing_if = "Option::is_none")]
1240 pub auth_fallback_from: Option<String>,
1241 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1248 pub local_last_resort: bool,
1249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1269 pub fallback_from: Vec<FallbackFrom>,
1270}
1271
1272#[derive(
1274 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
1275)]
1276pub struct FallbackFrom {
1277 pub candidate: String,
1279 pub reason: FallbackReason,
1280}
1281
1282#[derive(
1290 Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
1291)]
1292#[serde(rename_all = "snake_case")]
1293pub enum FallbackReason {
1294 CredentialRejected,
1303 CredentialAbsent,
1306 RateLimited,
1309 QuotaExhausted,
1315 TimedOut,
1323 Failed,
1329}
1330
1331pub fn classify_fallback_reason(error: &InferenceError) -> FallbackReason {
1364 use FallbackReason as R;
1365 match error {
1366 InferenceError::ProviderAccount { status, .. } => match status {
1369 402 => R::QuotaExhausted,
1370 _ => R::CredentialRejected,
1371 },
1372 InferenceError::CredentialUnavailable { reason, .. } => match reason {
1374 CredentialFailure::Expired { .. } => R::CredentialRejected,
1376 CredentialFailure::SignedOut | CredentialFailure::EnvVarMissing { .. } => {
1378 R::CredentialAbsent
1379 }
1380 CredentialFailure::StoreUnreadable | CredentialFailure::RaceRetryable => R::Failed,
1383 },
1384 InferenceError::Transient {
1385 status: Some(429), ..
1386 } => R::RateLimited,
1387 InferenceError::Transient { status: None, .. } => R::Failed,
1394 InferenceError::Transient { .. } => R::Failed,
1395 InferenceError::DeadlineExceeded { .. } => R::TimedOut,
1398 InferenceError::InferenceFailed(msg)
1399 | InferenceError::ProviderKeyMissing { message: msg, .. } => classify_untyped_failure(msg),
1400 _ => R::Failed,
1401 }
1402}
1403
1404fn classify_untyped_failure(msg: &str) -> FallbackReason {
1412 use FallbackReason as R;
1413 if let Some(status) = parse_api_returned_status(msg) {
1416 return match status {
1417 401 | 403 => R::CredentialRejected,
1418 402 => R::QuotaExhausted,
1419 429 => R::RateLimited,
1420 408 | 504 => R::TimedOut,
1421 _ => R::Failed,
1422 };
1423 }
1424 let l = msg.to_ascii_lowercase();
1425 if l.contains("org lookup failed") {
1428 return if l.contains("http 401") || l.contains("http 403") {
1429 R::CredentialRejected
1430 } else if l.contains("http 429") {
1431 R::RateLimited
1432 } else {
1433 R::Failed
1434 };
1435 }
1436 if l.contains("authentication required")
1437 || l.contains("invalid_grant")
1438 || l.contains("token expired")
1439 {
1440 return R::CredentialRejected;
1441 }
1442 if l.contains("no credential for proprietary") || l.contains("no api key") {
1443 return R::CredentialAbsent;
1444 }
1445 if l.contains("too many requests") || l.contains("rate limit") {
1446 return R::RateLimited;
1447 }
1448 if l.contains("timed out") || l.contains("deadline exceeded") {
1449 return R::TimedOut;
1450 }
1451 R::Failed
1452}
1453
1454pub fn record_fallback_from(hops: &mut Vec<FallbackFrom>, candidate: &str, error: &InferenceError) {
1460 hops.push(FallbackFrom {
1461 candidate: candidate.to_string(),
1462 reason: classify_fallback_reason(error),
1463 });
1464}
1465
1466pub struct TrackedStream {
1473 pub model_used: String,
1475 pub trace_id: String,
1477 pub events: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
1479}
1480
1481struct AbortOnDropTask<T>(Option<tokio::task::JoinHandle<T>>);
1482
1483impl<T> AbortOnDropTask<T> {
1484 async fn join(mut self) -> Result<T, tokio::task::JoinError> {
1485 self.0.take().expect("owned task handle available").await
1486 }
1487}
1488
1489impl<T> Drop for AbortOnDropTask<T> {
1490 fn drop(&mut self) {
1491 if let Some(task) = self.0.take() {
1492 task.abort();
1493 }
1494 }
1495}
1496
1497fn bound_model_identity(
1498 snapshot: &CatalogSnapshot,
1499 requested_model_id: Option<&str>,
1500 resolved_model_id: &str,
1501) -> Result<InferenceModelIdentity, InferenceError> {
1502 let row = snapshot.model_by_exact_id(resolved_model_id).ok_or_else(|| {
1503 InferenceError::InferenceFailed(format!(
1504 "resolved model `{resolved_model_id}` was absent from the catalog snapshot bound to this request"
1505 ))
1506 })?;
1507 Ok(InferenceModelIdentity {
1508 requested_model_id: requested_model_id.map(str::to_string),
1509 resolved_model_id: row.model.id.clone(),
1510 row_digest: row.row_digest.clone(),
1511 catalog_revision: snapshot.catalog_revision.clone(),
1512 })
1513}
1514
1515fn validate_expected_catalog_revision(
1516 req: &GenerateRequest,
1517 snapshot: &CatalogSnapshot,
1518) -> Result<(), InferenceError> {
1519 if let Some(expected) = req.expected_catalog_revision.as_deref() {
1520 if expected != snapshot.catalog_revision {
1521 return Err(InferenceError::CatalogPreconditionMismatch {
1522 detail: format!(
1523 "expected catalog revision {expected}, got {}",
1524 snapshot.catalog_revision
1525 ),
1526 });
1527 }
1528 }
1529 Ok(())
1530}
1531
1532fn validate_expected_catalog_row(
1533 req: &GenerateRequest,
1534 snapshot: &CatalogSnapshot,
1535 resolved_model_id: &str,
1536) -> Result<(), InferenceError> {
1537 let Some(expected) = req.expected_row_digest.as_deref() else {
1538 return Ok(());
1539 };
1540 let row = snapshot
1541 .model_by_exact_id(resolved_model_id)
1542 .ok_or_else(|| InferenceError::CatalogPreconditionMismatch {
1543 detail: format!(
1544 "resolved model `{resolved_model_id}` is absent from the bound catalog snapshot"
1545 ),
1546 })?;
1547 if expected != row.row_digest {
1548 return Err(InferenceError::CatalogPreconditionMismatch {
1549 detail: format!(
1550 "expected row digest {expected} for `{resolved_model_id}`, got {}",
1551 row.row_digest
1552 ),
1553 });
1554 }
1555 Ok(())
1556}
1557
1558const EXACT_MODEL_ID_PREFIX: &str = "\0car-exact-model-id:";
1559
1560pub fn pin_exact_model_id(req: &mut GenerateRequest, model_id: String) -> Result<(), String> {
1563 if req.model.is_some() {
1564 return Err("`model` and `model_id` are mutually exclusive".to_string());
1565 }
1566 if model_id.trim().is_empty() {
1567 return Err("`model_id` must be a non-empty immutable id".to_string());
1568 }
1569 req.model = Some(format!("{EXACT_MODEL_ID_PREFIX}{model_id}"));
1570 req.params.strict_model = true;
1571 Ok(())
1572}
1573
1574pub fn exact_pinned_model_id(req: &GenerateRequest) -> Option<&str> {
1575 req.model
1576 .as_deref()
1577 .and_then(|model| model.strip_prefix(EXACT_MODEL_ID_PREFIX))
1578}
1579
1580fn auto_thinking_budget(
1589 is_code_intent: bool,
1590 is_complex: bool,
1591 supports_thinking: bool,
1592) -> Option<usize> {
1593 if !supports_thinking {
1594 return None;
1595 }
1596 if is_code_intent {
1597 Some(24_000)
1598 } else if is_complex {
1599 Some(8_000)
1600 } else {
1601 None
1602 }
1603}
1604
1605fn is_explicit_code_intent(intent: Option<&intent::IntentHint>) -> bool {
1615 intent.and_then(|h| h.task) == Some(intent::TaskHint::Code)
1616}
1617
1618fn should_append_local_last_resort(chain_has_local: bool, strict_model: bool) -> bool {
1623 !chain_has_local && !strict_model
1624}
1625
1626fn resolved_max_tokens(requested: usize, schema: &ModelSchema) -> usize {
1650 if requested != crate::tasks::generate::DEFAULT_MAX_TOKENS
1651 || schema.decodes_in_process()
1652 || schema.is_codex_cli()
1653 {
1654 return requested;
1655 }
1656 schema.effective_max_output()
1657}
1658
1659const DEFAULT_LOCAL_DECODE_TIMEOUT_SECS: u64 = 300;
1672
1673pub const LOCAL_DECODE_TIMEOUT_STOP_REASON: &str = "local_decode_timeout";
1680
1681#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1689const LOCAL_DECODE_HEARTBEAT_SECS: u64 = 10;
1690
1691fn parse_decode_timeout(raw: Option<&str>) -> Option<std::time::Duration> {
1694 let secs = match raw {
1695 Some(v) => v
1696 .trim()
1697 .parse::<u64>()
1698 .unwrap_or(DEFAULT_LOCAL_DECODE_TIMEOUT_SECS),
1699 None => DEFAULT_LOCAL_DECODE_TIMEOUT_SECS,
1700 };
1701 (secs > 0).then(|| std::time::Duration::from_secs(secs))
1702}
1703
1704#[cfg_attr(
1717 not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
1718 allow(dead_code)
1719)]
1720fn deadline_exceeded(elapsed: std::time::Duration, timeout: Option<std::time::Duration>) -> bool {
1721 timeout.is_some_and(|limit| elapsed >= limit)
1722}
1723
1724#[cfg_attr(
1730 not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
1731 allow(dead_code)
1732)]
1733fn heartbeat_due(
1734 elapsed: std::time::Duration,
1735 last: std::time::Duration,
1736 interval: std::time::Duration,
1737) -> bool {
1738 elapsed.saturating_sub(last) >= interval
1739}
1740
1741#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1745enum EmptyPassAction {
1746 RetryWithoutThinking,
1749 FailDecodeCeiling,
1753 Accept,
1755}
1756
1757fn classify_empty_pass(
1765 recover: bool,
1766 stop_reason: Option<&str>,
1767 text: &str,
1768 tool_calls_empty: bool,
1769) -> EmptyPassAction {
1770 if !text.trim().is_empty() || !tool_calls_empty {
1771 return EmptyPassAction::Accept;
1772 }
1773 if stop_reason == Some(LOCAL_DECODE_TIMEOUT_STOP_REASON) {
1774 return EmptyPassAction::FailDecodeCeiling;
1775 }
1776 if recover {
1777 return EmptyPassAction::RetryWithoutThinking;
1778 }
1779 EmptyPassAction::Accept
1780}
1781
1782fn local_decode_timeout() -> Option<std::time::Duration> {
1783 parse_decode_timeout(
1784 std::env::var("CAR_LOCAL_DECODE_TIMEOUT_SECS")
1785 .ok()
1786 .as_deref(),
1787 )
1788}
1789
1790impl InferenceResult {
1791 pub fn served_model_id(&self) -> &str {
1796 if self.model_identity.resolved_model_id.is_empty() {
1797 &self.model_used
1798 } else {
1799 &self.model_identity.resolved_model_id
1800 }
1801 }
1802
1803 pub fn has_tool_calls(&self) -> bool {
1805 !self.tool_calls.is_empty()
1806 }
1807
1808 pub fn was_truncated(&self) -> bool {
1820 matches!(
1821 self.stop_reason.as_deref(),
1822 Some(
1823 "length"
1824 | "max_tokens"
1825 | "max_output_tokens"
1826 | "MAX_TOKENS"
1827 | crate::LOCAL_DECODE_TIMEOUT_STOP_REASON
1828 )
1829 )
1830 }
1831
1832 pub fn append_assistant_history(
1841 &self,
1842 messages: &mut Vec<crate::tasks::generate::Message>,
1843 tool_calls: Vec<crate::tasks::generate::ToolCall>,
1844 ) {
1845 if !self.provider_output_items.is_empty() {
1846 messages.push(crate::tasks::generate::Message::ProviderOutputItems {
1847 protocol: crate::protocol::OPENAI_RESPONSES_PROTOCOL.to_string(),
1848 items: self.provider_output_items.clone(),
1849 });
1850 }
1851 messages.push(crate::tasks::generate::Message::Assistant {
1852 content: self.text.clone(),
1853 tool_calls,
1854 thinking: self.thinking.clone(),
1855 model_id: Some(self.served_model_id().to_string()),
1856 local_last_resort: self.local_last_resort,
1857 });
1858 }
1859}
1860
1861#[derive(Debug, Clone, Serialize)]
1862pub struct SpeechRuntimeHealth {
1863 pub root: PathBuf,
1864 pub installed: bool,
1865 pub python: PathBuf,
1866 pub stt_command: PathBuf,
1867 pub tts_command: PathBuf,
1868 pub configured_python: Option<String>,
1869 pub detected_python: Option<String>,
1870}
1871
1872#[derive(Debug, Clone, Serialize)]
1873pub struct SpeechModelHealth {
1874 pub id: String,
1875 pub name: String,
1876 pub provider: String,
1877 pub capability: ModelCapability,
1878 pub is_local: bool,
1879 pub available: bool,
1880 pub cached: bool,
1881 pub selected_by_default: bool,
1882 pub source: String,
1883}
1884
1885#[derive(Debug, Clone, Serialize)]
1886pub struct SpeechHealthReport {
1887 pub runtime: SpeechRuntimeHealth,
1888 pub local_models: Vec<SpeechModelHealth>,
1889 pub remote_models: Vec<SpeechModelHealth>,
1890 pub elevenlabs_configured: bool,
1891 pub prefer_local: bool,
1892 pub allow_remote_fallback: bool,
1893 pub preferred_local_stt: Option<String>,
1894 pub preferred_local_tts: Option<String>,
1895 pub preferred_remote_stt: Option<String>,
1896 pub preferred_remote_tts: Option<String>,
1897 pub local_stt_default: Option<String>,
1898 pub local_tts_default: Option<String>,
1899 pub remote_stt_default: Option<String>,
1900 pub remote_tts_default: Option<String>,
1901}
1902
1903#[derive(Debug, Clone, Serialize)]
1904pub struct ModelDefaultHealth {
1905 pub capability: ModelCapability,
1906 pub configured_model: String,
1907 pub available: bool,
1908 pub is_local: bool,
1909 pub provider: Option<String>,
1910}
1911
1912#[derive(Debug, Clone, Serialize)]
1913pub struct ModelProviderHealth {
1914 pub provider: String,
1915 pub configured: bool,
1916 pub local_models: usize,
1917 pub remote_models: usize,
1918 pub available_models: usize,
1919 pub capabilities: Vec<ModelCapability>,
1920}
1921
1922#[derive(Debug, Clone, Serialize)]
1923pub struct ModelCapabilityHealth {
1924 pub capability: ModelCapability,
1925 pub total_models: usize,
1926 pub available_models: usize,
1927 pub local_available_models: usize,
1928 pub remote_available_models: usize,
1929}
1930
1931#[derive(Debug, Clone, Serialize)]
1932pub struct RoutingScenarioHealth {
1933 pub name: String,
1934 pub workload: RoutingWorkload,
1935 pub task_family: String,
1936 pub has_tools: bool,
1937 pub has_vision: bool,
1938 pub prefer_local: bool,
1939 pub quality_first_cold_start: bool,
1940 pub bootstrap_min_task_observations: u64,
1941 pub bootstrap_quality_floor: f64,
1942 pub model_id: String,
1943 pub model_name: String,
1944 pub reason: String,
1945 pub strategy: RoutingStrategy,
1946}
1947
1948#[derive(Debug, Clone, Serialize)]
1949pub struct ModelBenchmarkPriorHealth {
1950 pub model_id: String,
1951 pub model_name: Option<String>,
1952 pub overall_score: f64,
1953 pub overall_latency_ms: Option<f64>,
1954 pub task_scores: std::collections::HashMap<String, f64>,
1955 pub task_latency_ms: std::collections::HashMap<String, f64>,
1956 pub source_path: PathBuf,
1957}
1958
1959#[derive(Debug, Clone, Serialize)]
1960pub struct ModelHealthReport {
1961 pub total_models: usize,
1962 pub available_models: usize,
1963 pub local_models: usize,
1964 pub remote_models: usize,
1965 pub defaults: Vec<ModelDefaultHealth>,
1966 pub providers: Vec<ModelProviderHealth>,
1967 pub capabilities: Vec<ModelCapabilityHealth>,
1968 pub routing_prefer_local: bool,
1969 pub routing_quality_first_cold_start: bool,
1970 pub routing_min_observations: u64,
1971 pub routing_bootstrap_min_task_observations: u64,
1972 pub routing_bootstrap_quality_floor: f64,
1973 pub routing_quality_weight: f64,
1974 pub routing_latency_weight: f64,
1975 pub routing_cost_weight: f64,
1976 pub routing_scenarios: Vec<RoutingScenarioHealth>,
1977 pub benchmark_priors: Vec<ModelBenchmarkPriorHealth>,
1978 pub speech: SpeechHealthReport,
1979}
1980
1981#[derive(Debug, Clone, Serialize)]
1982pub struct SpeechInstallReport {
1983 pub name: String,
1984 pub hf_repo: String,
1985 pub snapshot_path: PathBuf,
1986 pub files_downloaded: usize,
1987}
1988
1989#[derive(Debug, Clone, Serialize)]
1990pub struct SpeechSmokePathReport {
1991 pub path: String,
1992 pub tts_model: String,
1993 pub stt_model: String,
1994 pub audio_path: PathBuf,
1995 pub transcript: String,
1996}
1997
1998#[derive(Debug, Clone, Serialize, Default)]
1999pub struct SpeechSmokeReport {
2000 pub local: Option<SpeechSmokePathReport>,
2001 pub remote: Option<SpeechSmokePathReport>,
2002 pub skipped: Vec<String>,
2003}
2004
2005#[derive(Debug, Clone, Serialize, Default)]
2006pub struct SpeechPolicy {
2007 pub prefer_local: bool,
2008 pub allow_remote_fallback: bool,
2009 pub preferred_local_stt: Option<String>,
2010 pub preferred_local_tts: Option<String>,
2011 pub preferred_remote_stt: Option<String>,
2012 pub preferred_remote_tts: Option<String>,
2013}
2014
2015fn render_for_local_backend(mut req: GenerateRequest) -> GenerateRequest {
2024 let has_msgs = req.messages.as_ref().is_some_and(|m| !m.is_empty());
2025 let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
2026 if has_msgs || has_tools {
2027 req.prompt = tasks::generate::render_chat_prompt(&req);
2028 req.messages = None;
2029 req.tools = None;
2030 }
2031 req
2032}
2033
2034#[cfg(test)]
2035static UNRETIRED_RELEASE_WARNING_COUNT: std::sync::atomic::AtomicUsize =
2036 std::sync::atomic::AtomicUsize::new(0);
2037
2038#[cfg(any(
2052 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2053 all(target_os = "ios", target_arch = "aarch64")
2054))]
2055fn foundation_models_usage(
2056 instructions: Option<&str>,
2057 prompt: &str,
2058 completion: &str,
2059 context_window: u64,
2060) -> Option<crate::TokenUsage> {
2061 let (prompt_tokens, completion_tokens) =
2062 crate::backend::foundation_models::count_tokens(instructions, prompt, completion)?;
2063 Some(crate::TokenUsage {
2064 prompt_tokens,
2065 completion_tokens,
2066 total_tokens: prompt_tokens + completion_tokens,
2067 context_window,
2068 ..Default::default()
2070 })
2071}
2072
2073pub struct InferenceEngine {
2074 pub config: InferenceConfig,
2075 pub unified_registry: UnifiedRegistry,
2077 pub adaptive_router: AdaptiveRouter,
2079 pub outcome_tracker: Arc<RwLock<OutcomeTracker>>,
2081 last_outcome_flush: Arc<std::sync::Mutex<Option<Instant>>>,
2087 ledger_io_lock: Arc<tokio::sync::Mutex<()>>,
2092 spend_limits: Arc<std::sync::RwLock<Option<SpendLimits>>>,
2098 lane_defaults_cache: Arc<std::sync::RwLock<crate::lane_defaults::LaneDefaults>>,
2103 concierge_action_lock: Arc<tokio::sync::Mutex<()>>,
2109 concierge_action_seq: Arc<std::sync::atomic::AtomicU64>,
2113 remote_backend: RemoteBackend,
2115 model_management: model_management::ModelManagementStore,
2118 local_admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2120 _runtime_scope: Arc<ScopedInferenceRuntime>,
2125 resource_policy_generation: Arc<std::sync::atomic::AtomicU64>,
2126 resource_policy_evidence: Arc<std::sync::RwLock<resource_policy::ResourcePolicyLoadEvidence>>,
2130 model_budget: Arc<backend_cache::SharedModelBudget>,
2133 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2139 mlx_backends: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
2140 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2147 local_backends:
2148 Arc<backend_cache::BackendCache<Box<dyn backend::local::LocalInferenceBackend>>>,
2149 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2154 flux_cache: Arc<backend_cache::BackendCache<backend::mlx_flux::FluxBackend>>,
2155 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2157 ltx_cache: Arc<backend_cache::BackendCache<backend::mlx_ltx::LtxBackend>>,
2158 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2161 kokoro_cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2162 pub registry: models::ModelRegistry,
2164 pub router: ModelRouter,
2165 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2166 backend: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
2167 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2168 embedding_backend: Arc<RwLock<Option<EmbeddingBackend>>>,
2169 speech_runtime: Arc<Mutex<Option<SpeechRuntime>>>,
2170 speech_policy: SpeechPolicy,
2171 vllm_pool: Arc<vllm_pool::VllmServerPool>,
2175}
2176
2177enum SpeechCandidateAdmission {
2178 Proceed(Option<resource_policy::LocalLoadReservation>),
2179 SkipBlocked(InferenceError),
2180 FailBlocked(InferenceError),
2181}
2182
2183struct ScopedInferenceRuntime {
2184 model_budget: Arc<backend_cache::SharedModelBudget>,
2185 resource_policy_generation: Arc<std::sync::atomic::AtomicU64>,
2186 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2187 mlx_backends: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
2188 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2189 local_backends:
2190 Arc<backend_cache::BackendCache<Box<dyn backend::local::LocalInferenceBackend>>>,
2191 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2192 flux_cache: Arc<backend_cache::BackendCache<backend::mlx_flux::FluxBackend>>,
2193 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2194 ltx_cache: Arc<backend_cache::BackendCache<backend::mlx_ltx::LtxBackend>>,
2195 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2196 kokoro_cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2197 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2198 backend: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
2199 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2200 embedding_backend: Arc<RwLock<Option<EmbeddingBackend>>>,
2201 speech_runtime: Arc<Mutex<Option<SpeechRuntime>>>,
2202 vllm_pool: Arc<vllm_pool::VllmServerPool>,
2203 #[cfg(test)]
2204 load_probe: Arc<backend_cache::BackendCache<()>>,
2205}
2206
2207fn scoped_inference_runtime_registry() -> &'static std::sync::Mutex<
2208 std::collections::HashMap<PathBuf, std::sync::Weak<ScopedInferenceRuntime>>,
2209> {
2210 static REGISTRY: std::sync::OnceLock<
2211 std::sync::Mutex<
2212 std::collections::HashMap<PathBuf, std::sync::Weak<ScopedInferenceRuntime>>,
2213 >,
2214 > = std::sync::OnceLock::new();
2215 REGISTRY.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
2216}
2217
2218fn configured_cache_budget_mb(default_mb: u64) -> u64 {
2219 std::env::var("CAR_INFERENCE_MODEL_CACHE_MB")
2220 .ok()
2221 .and_then(|value| value.parse::<u64>().ok())
2222 .unwrap_or(default_mb)
2223}
2224
2225fn scoped_inference_runtime(
2226 state_root: &Path,
2227 configured_ceiling_mb: u64,
2228 admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2229) -> Arc<ScopedInferenceRuntime> {
2230 let state_root = resource_policy::normalized_state_root_key(state_root);
2231 let mut registry = scoped_inference_runtime_registry()
2232 .lock()
2233 .unwrap_or_else(std::sync::PoisonError::into_inner);
2234 registry.retain(|_, runtime| runtime.strong_count() > 0);
2235 if let Some(runtime) = registry.get(&state_root).and_then(std::sync::Weak::upgrade) {
2236 runtime.model_budget.set_budget_bytes(
2237 configured_cache_budget_mb(configured_ceiling_mb).saturating_mul(1024 * 1024),
2238 );
2239 return runtime;
2240 }
2241
2242 let model_budget = backend_cache::SharedModelBudget::from_env_or(configured_ceiling_mb);
2243 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2244 let cache_idle = backend_cache::idle_ttl_from_env();
2245 let runtime = Arc::new(ScopedInferenceRuntime {
2246 model_budget: model_budget.clone(),
2247 resource_policy_generation: Arc::new(std::sync::atomic::AtomicU64::new(1)),
2248 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2249 mlx_backends: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2250 model_budget.clone(),
2251 cache_idle,
2252 Some(admission.clone()),
2253 )),
2254 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2255 local_backends: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2256 model_budget.clone(),
2257 cache_idle,
2258 Some(admission.clone()),
2259 )),
2260 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2261 flux_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2262 model_budget.clone(),
2263 cache_idle,
2264 Some(admission.clone()),
2265 )),
2266 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2267 ltx_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2268 model_budget.clone(),
2269 cache_idle,
2270 Some(admission.clone()),
2271 )),
2272 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2273 kokoro_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2274 model_budget,
2275 cache_idle,
2276 Some(admission.clone()),
2277 )),
2278 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2279 backend: Arc::new(RwLock::new(std::collections::HashMap::new())),
2280 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2281 embedding_backend: Arc::new(RwLock::new(None)),
2282 speech_runtime: Arc::new(Mutex::new(None)),
2283 vllm_pool: Arc::new(
2287 vllm_pool::VllmServerPool::with_admission(
2288 std::time::Duration::from_secs(
2289 std::env::var("CAR_VLLM_IDLE_SECS")
2290 .ok()
2291 .and_then(|value| value.parse().ok())
2292 .unwrap_or(300),
2293 ),
2294 admission,
2295 )
2296 .with_process_records(state_root.join("run").join("vllm-mlx")),
2297 ),
2298 #[cfg(test)]
2299 load_probe: Arc::new(backend_cache::BackendCache::new(1024)),
2300 });
2301 registry.insert(state_root, Arc::downgrade(&runtime));
2302 runtime
2303}
2304
2305#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2309pub struct ScopedKokoroBackendCache {
2310 cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2311 _runtime: Arc<ScopedInferenceRuntime>,
2312 admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2313}
2314
2315#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2316impl ScopedKokoroBackendCache {
2317 pub fn cache(&self) -> &Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>> {
2318 &self.cache
2319 }
2320
2321 pub fn admission(&self) -> &Arc<resource_policy::LocalAdmissionCoordinator> {
2322 &self.admission
2323 }
2324}
2325
2326#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2327pub fn scoped_kokoro_backend_cache(state_root: &Path) -> ScopedKokoroBackendCache {
2328 let state_root = resource_policy::normalized_state_root_key(state_root);
2329 let policy = resource_policy::FileResourcePolicyRepository::new(state_root.clone())
2330 .load()
2331 .unwrap_or_else(|_| resource_policy::ResourcePolicy::everyday());
2332 let hardware = HardwareInfo::detect();
2333 let admission = resource_policy::scoped_local_admission(&state_root, policy, hardware.clone());
2334 let ceiling_mb = admission
2335 .policy()
2336 .effective_budget(hardware.total_ram_mb)
2337 .configured_model_ceiling_mb;
2338 let runtime = scoped_inference_runtime(&state_root, ceiling_mb, admission.clone());
2339 ScopedKokoroBackendCache {
2340 cache: runtime.kokoro_cache.clone(),
2341 _runtime: runtime,
2342 admission,
2343 }
2344}
2345
2346impl InferenceEngine {
2347 fn requires_local_admission(schema: &ModelSchema) -> bool {
2348 schema.is_car_managed_vllm_mlx() || Self::supports_worker_offload(schema)
2349 }
2350
2351 fn reserve_in_outer_dispatch(schema: &ModelSchema) -> bool {
2357 Self::requires_local_admission(schema) && !schema.is_car_managed_vllm_mlx()
2358 }
2359
2360 fn supports_worker_offload(schema: &ModelSchema) -> bool {
2361 matches!(
2362 schema.source,
2363 ModelSource::Local { .. } | ModelSource::Mlx { .. }
2364 )
2365 }
2366
2367 pub fn local_model_retention(&self, model_id: &str) -> backend_cache::BackendRetention {
2370 if self.local_admission.is_resident(model_id) {
2371 backend_cache::BackendRetention::Resident
2372 } else {
2373 backend_cache::BackendRetention::Transient
2374 }
2375 }
2376 pub fn local_model_preflight(
2378 &self,
2379 model_id: &str,
2380 context_tokens: usize,
2381 ) -> Result<resource_policy::LocalLoadPreflight, InferenceError> {
2382 let schema = self
2383 .unified_registry
2384 .get(model_id)
2385 .or_else(|| self.unified_registry.find_by_name(model_id))
2386 .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
2387 self.ensure_model_enabled(&schema.id)?;
2388 Ok(self.local_admission.preflight(schema, context_tokens))
2389 }
2390
2391 pub fn active_local_resource_policy(&self) -> resource_policy::ResourcePolicyLoadEvidence {
2398 let mut evidence = self
2399 .resource_policy_evidence
2400 .read()
2401 .unwrap_or_else(|poisoned| poisoned.into_inner())
2402 .clone();
2403 evidence.policy = self.local_admission.policy();
2404 evidence
2405 }
2406
2407 pub fn apply_local_resource_policy(&self, policy: resource_policy::ResourcePolicy) {
2411 let ceiling_mb = policy
2412 .effective_budget(HardwareInfo::detect().total_ram_mb)
2413 .configured_model_ceiling_mb;
2414 self.local_admission.set_policy(policy.clone());
2415 *self
2416 .resource_policy_evidence
2417 .write()
2418 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
2419 resource_policy::ResourcePolicyLoadEvidence {
2420 policy,
2421 source: resource_policy::ResourcePolicyLoadSource::Loaded,
2422 warning: None,
2423 };
2424 let cache_ceiling_mb = configured_cache_budget_mb(ceiling_mb);
2425 self.model_budget
2426 .set_budget_bytes(cache_ceiling_mb.saturating_mul(1024 * 1024));
2427 let generation = self
2428 .resource_policy_generation
2429 .fetch_add(1, std::sync::atomic::Ordering::AcqRel)
2430 .saturating_add(1);
2431 if let Some(offload) = crate::offload::current_local_offload() {
2432 offload.refresh_resource_policy(generation);
2433 }
2434 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2435 {
2436 self.mlx_backends.enforce_budget();
2437 self.local_backends.enforce_budget();
2438 self.flux_cache.enforce_budget();
2439 self.ltx_cache.enforce_budget();
2440 self.kokoro_cache.enforce_budget();
2441 }
2442 }
2443
2444 pub fn begin_local_model_maintenance(
2445 &self,
2446 model_id: &str,
2447 ) -> Result<resource_policy::LocalModelMaintenanceGuard, resource_policy::ModelMaintenanceError>
2448 {
2449 self.local_admission.begin_model_maintenance(model_id)
2450 }
2451
2452 pub async fn prepare_local_model_removal(
2453 &self,
2454 model_id: &str,
2455 ) -> Result<resource_policy::LocalModelMaintenanceGuard, resource_policy::ModelMaintenanceError>
2456 {
2457 let maintenance = self.local_admission.begin_model_maintenance(model_id)?;
2458 if let Some(offload) = crate::offload::current_local_offload() {
2459 if offload
2460 .resident_models()
2461 .await
2462 .iter()
2463 .any(|resident| resident == model_id)
2464 {
2465 let acknowledged = offload.release_model(model_id).await.map_err(|error| {
2466 resource_policy::ModelMaintenanceError::ReleaseFailed(error.to_string())
2467 })?;
2468 if !acknowledged
2469 || offload
2470 .resident_models()
2471 .await
2472 .iter()
2473 .any(|resident| resident == model_id)
2474 {
2475 return Err(
2476 resource_policy::ModelMaintenanceError::WorkerReleaseUnacknowledged(
2477 model_id.to_string(),
2478 ),
2479 );
2480 }
2481 }
2482 }
2483 if self
2484 .vllm_pool
2485 .release_model_if_present(model_id)
2486 .await
2487 .is_err()
2488 {
2489 return Err(
2490 resource_policy::ModelMaintenanceError::ProcessReleaseUnacknowledged(
2491 model_id.to_string(),
2492 ),
2493 );
2494 }
2495 if !self.evict_local_model_if_idle(model_id) {
2496 return Err(resource_policy::ModelMaintenanceError::CacheReleaseBlocked(
2497 model_id.to_string(),
2498 ));
2499 }
2500 let allocation_ids = self.local_admission.resident_allocation_ids(model_id);
2501 if !allocation_ids.is_empty() {
2502 return Err(resource_policy::ModelMaintenanceError::ResidualResidency {
2503 model_id: model_id.to_string(),
2504 allocation_ids,
2505 });
2506 }
2507 Ok(maintenance)
2508 }
2509
2510 pub fn evict_local_model_if_idle(&self, model_id: &str) -> bool {
2514 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2515 {
2516 let mut allocation_ids = self.local_admission.resident_allocation_ids(model_id);
2517 if !allocation_ids.iter().any(|id| id == model_id) {
2518 allocation_ids.push(model_id.to_string());
2519 }
2520 allocation_ids.into_iter().all(|allocation_id| {
2521 [
2522 self.mlx_backends.evict_if_idle(&allocation_id),
2523 self.local_backends.evict_if_idle(&allocation_id),
2524 self.flux_cache.evict_if_idle(&allocation_id),
2525 self.ltx_cache.evict_if_idle(&allocation_id),
2526 self.kokoro_cache.evict_if_idle(&allocation_id),
2527 ]
2528 .into_iter()
2529 .all(|evicted| evicted)
2530 })
2531 }
2532 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2533 {
2534 let _ = model_id;
2535 let backend_released = self.backend.try_write().is_ok_and(|mut backends| {
2536 if backends.remove(model_id).is_some() {
2537 self.local_admission.mark_evicted(model_id);
2538 }
2539 true
2540 });
2541 backend_released
2545 && self
2546 .embedding_backend
2547 .try_write()
2548 .is_ok_and(|slot| slot.is_none())
2549 }
2550 }
2551
2552 fn reserve_local_request(
2553 &self,
2554 schema: &ModelSchema,
2555 context_tokens: usize,
2556 ) -> Result<resource_policy::LocalLoadReservation, InferenceError> {
2557 self.ensure_model_enabled(&schema.id)?;
2558 let activity_lease = self.model_management.acquire_lease(&schema.id)?;
2559 let mut reservation = self
2560 .local_admission
2561 .reserve(schema, context_tokens)
2562 .map_err(InferenceError::from)?;
2563 reservation.attach_activity_lease(activity_lease);
2564 Ok(reservation)
2565 }
2566
2567 async fn reserve_local_request_with_worker_retry(
2572 &self,
2573 schema: &ModelSchema,
2574 context_tokens: usize,
2575 ) -> Result<resource_policy::LocalLoadReservation, InferenceError> {
2576 let original_error = match self.reserve_local_request(schema, context_tokens) {
2577 Ok(reservation) => return Ok(reservation),
2578 Err(error) => error,
2579 };
2580 let first_verdict = match &original_error {
2581 InferenceError::LocalResourceBlocked { preflight, .. } => preflight.verdict.clone(),
2582 _ => return Err(original_error),
2583 };
2584 if first_verdict != resource_policy::LocalLoadVerdict::InsufficientLiveMemory {
2585 return Err(original_error);
2586 }
2587
2588 let Some(offload) = crate::offload::current_local_offload() else {
2589 tracing::info!(
2590 model = %schema.id,
2591 first_verdict = ?first_verdict,
2592 release_happened = false,
2593 second_verdict = ?Option::<resource_policy::LocalLoadVerdict>::None,
2594 "local admission worker release-and-retry decision"
2595 );
2596 return Err(original_error);
2597 };
2598 if !offload
2599 .resident_models()
2600 .await
2601 .iter()
2602 .any(|model_id| model_id == &schema.id)
2603 {
2604 tracing::info!(
2605 model = %schema.id,
2606 first_verdict = ?first_verdict,
2607 release_happened = false,
2608 second_verdict = ?Option::<resource_policy::LocalLoadVerdict>::None,
2609 "local admission worker release-and-retry decision"
2610 );
2611 return Err(original_error);
2612 }
2613 let Some(scoped_allocation_id) = offload.resident_allocation_id(&schema.id) else {
2614 tracing::debug!(
2615 model = %schema.id,
2616 "release-and-retry skipped because the offload exposes no allocation id"
2617 );
2618 return Err(original_error);
2619 };
2620 match offload.release_model(&schema.id).await {
2621 Ok(true) => {
2622 let offload_still_resident = offload
2626 .resident_models()
2627 .await
2628 .iter()
2629 .any(|model_id| model_id == &schema.id);
2630 let allocation_still_charged = self
2633 .local_admission
2634 .resident_allocation_ids(&schema.id)
2635 .iter()
2636 .any(|allocation_id| allocation_id == &scoped_allocation_id);
2637 if offload_still_resident || allocation_still_charged {
2638 tracing::warn!(
2642 model = %schema.id,
2643 allocation = %scoped_allocation_id,
2644 offload_still_resident,
2645 allocation_still_charged,
2646 "release acknowledged but allocation {} still charged/pending",
2647 scoped_allocation_id
2648 );
2649 #[cfg(test)]
2650 UNRETIRED_RELEASE_WARNING_COUNT
2651 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2652 return Err(original_error);
2653 }
2654 let second = self.reserve_local_request(schema, context_tokens);
2655 let second_verdict = match &second {
2656 Ok(_) => Some("admitted".to_string()),
2657 Err(InferenceError::LocalResourceBlocked { preflight, .. }) => {
2658 Some(format!("{:?}", preflight.verdict))
2659 }
2660 Err(_) => None,
2661 };
2662 tracing::info!(
2663 model = %schema.id,
2664 first_verdict = ?first_verdict,
2665 release_happened = true,
2666 second_verdict = ?second_verdict,
2667 "local admission worker release-and-retry decision"
2668 );
2669 second
2670 }
2671 Ok(false) | Err(_) => {
2672 tracing::info!(
2673 model = %schema.id,
2674 first_verdict = ?first_verdict,
2675 release_happened = false,
2676 second_verdict = ?Option::<resource_policy::LocalLoadVerdict>::None,
2677 "local admission worker release-and-retry decision"
2678 );
2679 Err(original_error)
2680 }
2681 }
2682 }
2683
2684 #[cfg(any(
2689 test,
2690 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))
2691 ))]
2692 fn reconcile_transient_local_allocation(
2693 reservation: &mut resource_policy::LocalLoadReservation,
2694 measured_weights_bytes: u64,
2695 ) -> Result<(), InferenceError> {
2696 reservation
2697 .reconcile_measured_weights(measured_weights_bytes)
2698 .map(|_| ())
2699 .map_err(InferenceError::from)
2700 }
2701
2702 fn prepare_worker_admission(
2703 &self,
2704 schema: &ModelSchema,
2705 reservation: &mut resource_policy::LocalLoadReservation,
2706 ) -> Result<crate::offload::LocalWorkerAdmission, InferenceError> {
2707 let installed = self.config.models_dir.join(&schema.name);
2708 let installed_weights_bytes = backend_cache::estimate_model_size(&installed);
2709 let measured_weights_bytes =
2716 installed_weights_bytes.max(reservation.reconciled_weights_bytes());
2717 reservation
2718 .reconcile_measured_weights(measured_weights_bytes)
2719 .map_err(InferenceError::from)?;
2720 Ok(crate::offload::LocalWorkerAdmission {
2721 policy: self.local_admission.policy(),
2722 policy_generation: self
2723 .resource_policy_generation
2724 .load(std::sync::atomic::Ordering::Acquire),
2725 state_root: resource_policy::normalized_state_root_key(&self.config.state_root),
2726 measured_weights_bytes,
2727 })
2728 }
2729
2730 async fn reconcile_worker_residency(
2731 offload: &dyn crate::offload::LocalGenerationOffload,
2732 expected_model_id: &str,
2733 residency: &crate::offload::LocalWorkerResidency,
2734 retention: backend_cache::BackendRetention,
2735 reservation: &mut resource_policy::LocalLoadReservation,
2736 ) -> Result<(), InferenceError> {
2737 if residency.model_id != expected_model_id {
2738 if retention == backend_cache::BackendRetention::Resident {
2739 let released = offload
2740 .release_model(&residency.model_id)
2741 .await
2742 .unwrap_or(false);
2743 if !released {
2744 let allocation_id = offload
2745 .resident_allocation_id(&residency.model_id)
2746 .unwrap_or_else(|| {
2747 resource_policy::worker_process_allocation_id(&residency.model_id)
2748 });
2749 reservation.publish_resident_weights_as(
2750 &allocation_id,
2751 residency.measured_weights_bytes,
2752 );
2753 }
2754 }
2755 return Err(InferenceError::InferenceFailed(format!(
2756 "local worker acknowledged model '{}' for requested '{}'",
2757 residency.model_id, expected_model_id
2758 )));
2759 }
2760 if retention == backend_cache::BackendRetention::Resident {
2761 let allocation_id = offload
2762 .resident_allocation_id(expected_model_id)
2763 .unwrap_or_else(|| {
2764 resource_policy::worker_process_allocation_id(expected_model_id)
2765 });
2766 reservation
2767 .publish_resident_weights_as(&allocation_id, residency.measured_weights_bytes);
2768 }
2769 Ok(())
2770 }
2771
2772 fn admit_speech_candidate(
2773 &self,
2774 schema: &ModelSchema,
2775 explicit: bool,
2776 ) -> SpeechCandidateAdmission {
2777 if !schema.is_local()
2778 || matches!(
2779 schema.source,
2780 ModelSource::WindowsSpeech {} | ModelSource::AppleFoundationModels { .. }
2781 )
2782 {
2783 return SpeechCandidateAdmission::Proceed(None);
2784 }
2785 match self.reserve_local_request(schema, 0) {
2786 Ok(reservation) => SpeechCandidateAdmission::Proceed(Some(reservation)),
2787 Err(error) if explicit => SpeechCandidateAdmission::FailBlocked(error),
2788 Err(error) => SpeechCandidateAdmission::SkipBlocked(error),
2789 }
2790 }
2791
2792 fn hold_local_reservation_for_stream(
2793 mut source: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
2794 reservation: resource_policy::LocalLoadReservation,
2795 ) -> tokio::sync::mpsc::Receiver<stream::StreamEvent> {
2796 let (tx, rx) = tokio::sync::mpsc::channel(64);
2797 tokio::spawn(async move {
2798 let _reservation = reservation;
2799 while let Some(event) = source.recv().await {
2800 if tx.send(event).await.is_err() {
2801 break;
2802 }
2803 }
2804 });
2805 rx
2806 }
2807
2808 fn hold_optional_reservation_for_stream(
2809 source: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
2810 reservation: Option<resource_policy::LocalLoadReservation>,
2811 ) -> tokio::sync::mpsc::Receiver<stream::StreamEvent> {
2812 match reservation {
2813 Some(reservation) => Self::hold_local_reservation_for_stream(source, reservation),
2814 None => source,
2815 }
2816 }
2817
2818 pub fn set_spend_limits(&self, limits: Option<SpendLimits>) {
2824 *self.spend_limits.write().unwrap() = limits;
2825 }
2826
2827 fn preferred_model_for_capability(&self, capability: ModelCapability) -> Option<&str> {
2828 match capability {
2829 ModelCapability::Generate => self.config.preferred_generation_model.as_deref(),
2830 ModelCapability::Embed => self.config.preferred_embedding_model.as_deref(),
2831 ModelCapability::Classify => self.config.preferred_classification_model.as_deref(),
2832 _ => None,
2833 }
2834 }
2835
2836 fn request_has_tools(req: &GenerateRequest) -> bool {
2842 req.tools.as_ref().is_some_and(|t| !t.is_empty())
2843 }
2844
2845 fn request_needs_vision(req: &GenerateRequest) -> bool {
2846 req.images.as_ref().is_some_and(|images| !images.is_empty())
2847 || req.messages.as_ref().is_some_and(|messages| {
2848 messages
2849 .iter()
2850 .any(|msg| matches!(msg, Message::UserMultimodal { .. }))
2851 })
2852 }
2853
2854 #[allow(dead_code)] fn request_has_video(req: &GenerateRequest) -> bool {
2860 let images_have_video = req
2861 .images
2862 .as_ref()
2863 .is_some_and(|blocks| blocks.iter().any(ContentBlock::is_video));
2864 let messages_have_video = req.messages.as_ref().is_some_and(|messages| {
2865 messages.iter().any(|msg| match msg {
2866 Message::UserMultimodal { content } => content.iter().any(ContentBlock::is_video),
2867 _ => false,
2868 })
2869 });
2870 images_have_video || messages_have_video
2871 }
2872
2873 #[allow(dead_code)] fn request_has_audio(req: &GenerateRequest) -> bool {
2878 let images_have_audio = req
2879 .images
2880 .as_ref()
2881 .is_some_and(|blocks| blocks.iter().any(ContentBlock::is_audio));
2882 let messages_have_audio = req.messages.as_ref().is_some_and(|messages| {
2883 messages.iter().any(|msg| match msg {
2884 Message::UserMultimodal { content } => content.iter().any(ContentBlock::is_audio),
2885 _ => false,
2886 })
2887 });
2888 images_have_audio || messages_have_audio
2889 }
2890
2891 pub fn new(config: InferenceConfig) -> Self {
2892 let registry = models::ModelRegistry::new(config.models_dir.clone());
2893 let hw = HardwareInfo::detect();
2894 let policy_evidence =
2895 resource_policy::FileResourcePolicyRepository::new(config.state_root.clone())
2896 .load_with_evidence()
2897 .unwrap_or_else(|error| {
2898 tracing::warn!(%error, "failed to read local model resource policy; using Everyday");
2899 resource_policy::ResourcePolicyLoadEvidence {
2900 policy: resource_policy::ResourcePolicy::everyday(),
2901 source: resource_policy::ResourcePolicyLoadSource::CorruptDefault,
2902 warning: Some(format!(
2903 "The local-model resource policy could not be read ({error}); CAR used Everyday."
2904 )),
2905 }
2906 });
2907 let policy = policy_evidence.policy.clone();
2908 let effective_budget = policy.effective_budget(hw.total_ram_mb);
2909 let local_admission = resource_policy::scoped_local_admission_with_models_dir(
2910 &config.state_root,
2911 policy,
2912 hw.clone(),
2913 Some(config.models_dir.clone()),
2914 );
2915 let runtime_scope = scoped_inference_runtime(
2916 &config.state_root,
2917 effective_budget.configured_model_ceiling_mb,
2918 local_admission.clone(),
2919 );
2920 let router = ModelRouter::new(hw.clone());
2921 let unified_registry = UnifiedRegistry::new_with_state_root(
2922 config.state_root.clone(),
2923 config.models_dir.clone(),
2924 );
2925 let adaptive_router = AdaptiveRouter::with_default_config(hw);
2926 let mut tracker = OutcomeTracker::new();
2927 let profiles_path = config.state_models_dir().join("outcome_profiles.json");
2929 if let Ok(n) = tracker.load_from_file(&profiles_path) {
2930 if n > 0 {
2931 tracing::info!(loaded = n, "loaded persisted model profiles");
2932 }
2933 }
2934 let mut benchmark_models_loaded = 0usize;
2935 for path in benchmark_priors_paths(&config.state_models_dir()) {
2936 match routing_ext::load_benchmark_priors(&path) {
2937 Ok(priors) if !priors.is_empty() => {
2938 benchmark_models_loaded += priors.len();
2939 routing_ext::apply_benchmark_priors(&mut tracker, &priors);
2940 tracing::info!(
2941 path = %path.display(),
2942 loaded = priors.len(),
2943 "loaded benchmark quality priors"
2944 );
2945 }
2946 Ok(_) => {}
2947 Err(error) => {
2948 tracing::warn!(path = %path.display(), %error, "failed to load benchmark priors");
2949 }
2950 }
2951 }
2952 if benchmark_models_loaded > 0 {
2953 tracing::info!(
2954 loaded = benchmark_models_loaded,
2955 "applied benchmark priors to cold-start routing"
2956 );
2957 }
2958 let outcome_tracker = Arc::new(RwLock::new(tracker));
2959
2960 let remote_backend = RemoteBackend::new();
2961 let model_management = model_management::ModelManagementStore::new(
2962 config.state_root.clone(),
2963 config.models_dir.clone(),
2964 );
2965
2966 Self {
2967 config,
2968 unified_registry,
2969 adaptive_router,
2970 outcome_tracker,
2971 last_outcome_flush: Arc::new(std::sync::Mutex::new(None)),
2972 ledger_io_lock: Arc::new(tokio::sync::Mutex::new(())),
2973 spend_limits: Arc::new(std::sync::RwLock::new(None)),
2974 lane_defaults_cache: Arc::new(std::sync::RwLock::new(crate::lane_defaults::load_from(
2975 &crate::lane_defaults::default_path(),
2976 ))),
2977 concierge_action_lock: Arc::new(tokio::sync::Mutex::new(())),
2978 concierge_action_seq: Arc::new(std::sync::atomic::AtomicU64::new(
2979 crate::action_ledger::read_actions(&crate::action_ledger::default_path(), 0)
2980 .iter()
2981 .map(|a| a.seq)
2982 .max()
2983 .map(|m| m + 1)
2984 .unwrap_or(1),
2985 )),
2986 remote_backend,
2987 model_management,
2988 local_admission: local_admission.clone(),
2989 _runtime_scope: runtime_scope.clone(),
2990 resource_policy_generation: runtime_scope.resource_policy_generation.clone(),
2991 resource_policy_evidence: Arc::new(std::sync::RwLock::new(policy_evidence)),
2992 model_budget: runtime_scope.model_budget.clone(),
2993 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2994 mlx_backends: runtime_scope.mlx_backends.clone(),
2995 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2996 local_backends: runtime_scope.local_backends.clone(),
2997 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2998 flux_cache: runtime_scope.flux_cache.clone(),
2999 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3000 ltx_cache: runtime_scope.ltx_cache.clone(),
3001 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3002 kokoro_cache: runtime_scope.kokoro_cache.clone(),
3003 registry,
3004 router,
3005 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3006 backend: runtime_scope.backend.clone(),
3007 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3008 embedding_backend: runtime_scope.embedding_backend.clone(),
3009 speech_runtime: runtime_scope.speech_runtime.clone(),
3010 speech_policy: SpeechPolicy {
3011 prefer_local: cfg!(all(
3012 target_os = "macos",
3013 target_arch = "aarch64",
3014 not(car_skip_mlx)
3015 )),
3016 allow_remote_fallback: true,
3017 preferred_local_stt: None,
3018 preferred_local_tts: None,
3019 preferred_remote_stt: None,
3020 preferred_remote_tts: None,
3021 },
3022 vllm_pool: runtime_scope.vllm_pool.clone(),
3023 }
3024 }
3025
3026 pub async fn init_key_pool(&self) {
3029 for schema in self.unified_registry.list() {
3031 if schema.is_remote() {
3032 self.remote_backend.register_model_keys(schema).await;
3033 }
3034 }
3035
3036 let stats_path = self.config.state_models_dir().join("key_pool_stats.json");
3038 if let Ok(n) = self.remote_backend.key_pool.load_stats(&stats_path).await {
3039 if n > 0 {
3040 tracing::info!(loaded = n, "loaded persisted key pool stats");
3041 }
3042 }
3043
3044 let total = self.remote_backend.key_pool.total_keys().await;
3045 if total > 0 {
3046 tracing::info!(keys = total, "key pool initialized");
3047 }
3048 }
3049
3050 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3053 async fn ensure_backend(
3054 &self,
3055 schema: &ModelSchema,
3056 reservation: &mut resource_policy::LocalLoadReservation,
3057 ) -> Result<(), InferenceError> {
3058 let read = self.backend.read().await;
3059 if read.contains_key(&schema.id) {
3060 return Ok(());
3061 }
3062 drop(read);
3063
3064 let mut write = self.backend.write().await;
3065 if write.contains_key(&schema.id) {
3066 return Ok(());
3067 }
3068
3069 let model_path = self.registry.ensure_model(&schema.name).await?;
3070 let mut measured = backend_cache::estimate_model_size(&model_path);
3071 reservation
3072 .reconcile_measured_weights(measured)
3073 .map_err(InferenceError::from)?;
3074 let device = self.config.device.unwrap_or_else(Device::auto);
3075 let backend = match CandleBackend::load(&model_path, device) {
3077 Ok(b) => b,
3078 Err(load_err) => {
3079 if crate::download::purge_corrupt_cache_files(&model_path) == 0 {
3083 return Err(load_err);
3084 }
3085 tracing::warn!(
3086 model = %schema.id,
3087 error = %load_err,
3088 "candle backend load failed; purged corrupt cache files and re-pulling once"
3089 );
3090 let model_path = self.registry.ensure_model(&schema.name).await?;
3091 measured = backend_cache::estimate_model_size(&model_path);
3092 reservation
3093 .reconcile_measured_weights(measured)
3094 .map_err(InferenceError::from)?;
3095 CandleBackend::load(&model_path, device)?
3096 }
3097 };
3098 write.insert(schema.id.clone(), backend);
3099 reservation.publish_resident_weights(measured);
3100 Ok(())
3101 }
3102
3103 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3106 async fn ensure_embedding_backend(
3107 &self,
3108 reservation: &mut resource_policy::LocalLoadReservation,
3109 ) -> Result<(), InferenceError> {
3110 let read = self.embedding_backend.read().await;
3111 if read.is_some() {
3112 return Ok(());
3113 }
3114 drop(read);
3115
3116 let mut write = self.embedding_backend.write().await;
3117 if write.is_some() {
3118 return Ok(());
3119 }
3120
3121 let embedding_model = self
3122 .preferred_model_for_capability(ModelCapability::Embed)
3123 .unwrap_or(&self.config.embedding_model);
3124 let model_path = self.registry.ensure_model(embedding_model).await?;
3125 let mut measured = backend_cache::estimate_model_size(&model_path);
3126 reservation
3127 .reconcile_measured_weights(measured)
3128 .map_err(InferenceError::from)?;
3129 let device = self.config.device.unwrap_or_else(Device::auto);
3130 let backend = match EmbeddingBackend::load(&model_path, device) {
3132 Ok(b) => b,
3133 Err(load_err) => {
3134 if crate::download::purge_corrupt_cache_files(&model_path) == 0 {
3135 return Err(load_err);
3136 }
3137 tracing::warn!(
3138 model = embedding_model,
3139 error = %load_err,
3140 "embedding backend load failed; purged corrupt cache files and re-pulling once"
3141 );
3142 let model_path = self.registry.ensure_model(embedding_model).await?;
3143 measured = backend_cache::estimate_model_size(&model_path);
3144 reservation
3145 .reconcile_measured_weights(measured)
3146 .map_err(InferenceError::from)?;
3147 EmbeddingBackend::load(&model_path, device)?
3148 }
3149 };
3150 *write = Some(backend);
3151 reservation.publish_resident_weights(measured);
3152 Ok(())
3153 }
3154
3155 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3158 async fn ensure_mlx_embedding_backend(&self) -> Result<String, InferenceError> {
3159 let embedding_model_name = self
3160 .preferred_model_for_capability(ModelCapability::Embed)
3161 .unwrap_or(&self.config.embedding_model)
3162 .to_string();
3163 let schema = self
3164 .unified_registry
3165 .get(&embedding_model_name)
3166 .or_else(|| self.unified_registry.find_by_name(&embedding_model_name))
3167 .ok_or_else(|| InferenceError::ModelNotFound(embedding_model_name.clone()))?
3168 .clone();
3169 Ok(schema.id)
3170 }
3171
3172 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3188 async fn load_backend_healing<T, F, RF, R>(
3189 schema_id: &str,
3190 model_dir: std::path::PathBuf,
3191 cache: &backend_cache::BackendCache<T>,
3192 size: u64,
3193 reservation: &mut resource_policy::LocalLoadReservation,
3194 loader: F,
3195 repull: RF,
3196 ) -> Result<
3197 (
3198 backend_cache::CachedBackend<T>,
3199 backend_cache::BackendRetention,
3200 ),
3201 InferenceError,
3202 >
3203 where
3204 T: Send + 'static,
3205 F: Fn(&Path) -> Result<T, InferenceError>,
3206 RF: FnOnce() -> R,
3207 R: std::future::Future<Output = Result<std::path::PathBuf, InferenceError>>,
3208 {
3209 match cache.get_or_load_admitted(schema_id, size, reservation, || loader(&model_dir)) {
3210 Ok(admitted) => Ok(admitted),
3211 Err(load_err) => {
3212 let purged = crate::download::purge_corrupt_cache_files(&model_dir);
3220 if purged == 0 {
3221 return Err(load_err);
3223 }
3224 tracing::warn!(
3225 model = schema_id,
3226 purged,
3227 error = %load_err,
3228 "backend load failed; purged corrupt cache files and re-pulling once"
3229 );
3230 let fresh_dir = repull().await?;
3231 let fresh_size = backend_cache::estimate_model_size(&fresh_dir);
3232 cache
3233 .get_or_load_admitted(schema_id, fresh_size, reservation, || loader(&fresh_dir))
3234 }
3235 }
3236 }
3237
3238 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3242 async fn ensure_mlx_backend(
3243 &self,
3244 schema: &ModelSchema,
3245 reservation: &mut resource_policy::LocalLoadReservation,
3246 ) -> Result<
3247 (
3248 backend_cache::CachedBackend<backend::MlxBackend>,
3249 backend_cache::BackendRetention,
3250 ),
3251 InferenceError,
3252 > {
3253 if !Self::supports_native_mlx(schema) {
3254 return Err(InferenceError::InferenceFailed(format!(
3255 "native MLX backend does not support {} ({}) yet; use vLLM-MLX or add a family-specific MLX backend",
3256 schema.name, schema.family
3257 )));
3258 }
3259
3260 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3261 let size = backend_cache::estimate_model_size(&model_dir);
3262 if !reservation.authorizes_model(&schema.id) {
3263 return Err(InferenceError::InferenceFailed(format!(
3264 "local admission reservation does not authorize {}",
3265 schema.id
3266 )));
3267 }
3268 let loader = |dir: &Path| {
3272 let dir = dir.to_path_buf();
3273 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3274 backend::MlxBackend::load(&dir)
3276 }))
3277 .map_err(|e| {
3278 InferenceError::InferenceFailed(format!(
3279 "MLX backend loading panicked (possible Metal/accelerate exception): {:?}",
3280 e
3281 ))
3282 })?
3283 };
3284 Self::load_backend_healing(
3285 &schema.id,
3286 model_dir,
3287 &self.mlx_backends,
3288 size,
3289 reservation,
3290 loader,
3291 || self.unified_registry.redownload_local(&schema.id),
3292 )
3293 .await
3294 }
3295
3296 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3301 async fn ensure_local_backend(
3302 &self,
3303 schema: &ModelSchema,
3304 reservation: &mut resource_policy::LocalLoadReservation,
3305 ) -> Result<
3306 (
3307 backend_cache::CachedBackend<Box<dyn backend::local::LocalInferenceBackend>>,
3308 backend_cache::BackendRetention,
3309 ),
3310 InferenceError,
3311 > {
3312 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3313 let size = backend_cache::estimate_model_size(&model_dir);
3314 if !reservation.authorizes_model(&schema.id) {
3315 return Err(InferenceError::InferenceFailed(format!(
3316 "local admission reservation does not authorize {}",
3317 schema.id
3318 )));
3319 }
3320 let loader = |dir: &Path| {
3321 let dir = dir.to_path_buf();
3322 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3323 backend::local::local_backend_for(&dir)
3325 }))
3326 .map_err(|e| {
3327 InferenceError::InferenceFailed(format!(
3328 "local backend loading panicked (possible Metal/accelerate exception): {:?}",
3329 e
3330 ))
3331 })?
3332 };
3333 Self::load_backend_healing(
3334 &schema.id,
3335 model_dir,
3336 &self.local_backends,
3337 size,
3338 reservation,
3339 loader,
3340 || self.unified_registry.redownload_local(&schema.id),
3341 )
3342 .await
3343 }
3344
3345 pub async fn reset_local_kv_cache(&self, model_id: &str) {
3356 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3360 {
3361 let Some(schema) = self.unified_registry.get(model_id).cloned() else {
3362 return;
3363 };
3364 if !matches!(
3368 schema.source,
3369 ModelSource::Mlx { .. } | ModelSource::Local { .. }
3370 ) {
3371 return;
3372 }
3373 if !self.local_backends.contains(&schema.id) {
3376 return;
3377 }
3378 let Ok(mut reservation) = self.reserve_local_request(&schema, 0) else {
3379 return;
3380 };
3381 if let Ok((handle, _retention)) =
3382 self.ensure_local_backend(&schema, &mut reservation).await
3383 {
3384 if let Ok(mut guard) = handle.lock() {
3385 guard.clear_kv_cache();
3386 }
3387 }
3388 }
3389 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3390 let _ = model_id;
3391 }
3392
3393 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3400 pub async fn warm_up<S: AsRef<str>>(
3401 &self,
3402 schema_ids: &[S],
3403 ) -> Vec<Result<(), InferenceError>> {
3404 let mut results = Vec::with_capacity(schema_ids.len());
3405 for id in schema_ids {
3406 let id = id.as_ref();
3407 let outcome: Result<(), InferenceError> = async {
3408 let schema = self.unified_registry.get(id).cloned().ok_or_else(|| {
3409 InferenceError::InferenceFailed(format!("warm_up: unknown schema id {id}"))
3410 })?;
3411 let mut reservation = self.reserve_local_request(&schema, 0)?;
3413 match schema.capabilities.first().copied() {
3414 Some(ModelCapability::ImageGeneration) => {
3415 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3416 let size = backend_cache::estimate_model_size(&model_dir);
3417 let _ = self.flux_cache.get_or_load_admitted(
3418 &schema.id,
3419 size,
3420 &mut reservation,
3421 || backend::mlx_flux::FluxBackend::load(&model_dir),
3422 )?;
3423 }
3424 Some(ModelCapability::VideoGeneration) => {
3425 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3426 let size = backend_cache::estimate_model_size(&model_dir);
3427 let _ = self.ltx_cache.get_or_load_admitted(
3428 &schema.id,
3429 size,
3430 &mut reservation,
3431 || {
3432 backend::mlx_ltx::LtxBackend::load(&model_dir)
3434 },
3435 )?;
3436 }
3437 Some(ModelCapability::TextToSpeech) => {
3438 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3439 let size = backend_cache::estimate_model_size(&model_dir);
3440 let cache_key = reservation.model_id().to_string();
3441 let _ = self.kokoro_cache.get_or_load_admitted(
3442 &cache_key,
3443 size,
3444 &mut reservation,
3445 || backend::mlx_kokoro::KokoroBackend::load(&model_dir),
3446 )?;
3447 }
3448 _ => {
3449 let _ = self.ensure_mlx_backend(&schema, &mut reservation).await?;
3450 }
3451 }
3452 Ok(())
3453 }
3454 .await;
3455 results.push(outcome);
3456 }
3457 results
3458 }
3459
3460 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3462 pub async fn warm_up<S: AsRef<str>>(
3463 &self,
3464 _schema_ids: &[S],
3465 ) -> Vec<Result<(), InferenceError>> {
3466 Vec::new()
3467 }
3468
3469 async fn vllm_live_schema(
3475 &self,
3476 schema: ModelSchema,
3477 reservation: Option<resource_policy::LocalLoadReservation>,
3478 context_tokens: usize,
3479 ) -> Result<(ModelSchema, Option<resource_policy::LocalLoadReservation>), InferenceError> {
3480 match &schema.source {
3481 ModelSource::ManagedVllmMlx { .. } => {}
3482 ModelSource::VllmMlx { .. } => return Ok((schema, None)),
3483 _ => return Ok((schema, None)),
3484 }
3485 let _dispatch = self.vllm_pool.acquire_dispatch(&schema.id).await;
3490 self.vllm_pool
3491 .wait_for_teardown(&schema.id, std::time::Duration::from_secs(5))
3492 .await
3493 .map_err(InferenceError::InferenceFailed)?;
3494 self.vllm_pool
3499 .reap_dead(&schema.id)
3500 .await
3501 .map_err(InferenceError::InferenceFailed)?;
3502 let mut reservation = match reservation {
3503 Some(reservation) if reservation.model_id() == schema.id => reservation,
3504 _ => self.reserve_local_request(&schema, context_tokens)?,
3505 };
3506 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3507 let model_name = model_dir.display().to_string();
3508 let measured = backend_cache::estimate_model_size(&model_dir)
3509 .max(reservation.reconciled_weights_bytes());
3510 reservation
3511 .reconcile_measured_weights(measured)
3512 .map_err(InferenceError::from)?;
3513 let endpoint = self
3515 .vllm_pool
3516 .ensure(&schema.id, &model_name, &reservation, &schema.family)
3519 .await
3520 .map_err(InferenceError::InferenceFailed)?;
3521 let allocation_id = resource_policy::vllm_process_allocation_id(&schema.id);
3522 reservation.publish_resident_weights_as(&allocation_id, measured);
3523 let mut schema = schema;
3524 schema.source = ModelSource::VllmMlx {
3525 endpoint,
3526 model_name,
3527 };
3528 Ok((schema, Some(reservation)))
3529 }
3530
3531 pub async fn evict_idle_vllm_servers(&self) -> usize {
3534 self.vllm_pool.evict_idle().await
3535 }
3536
3537 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3548 pub fn evict_idle_backends(&self) -> (usize, u64) {
3549 let mut entries = 0usize;
3550 let mut bytes = 0u64;
3551 for (n, b) in [
3552 self.mlx_backends.evict_idle(),
3553 self.local_backends.evict_idle(),
3554 self.flux_cache.evict_idle(),
3555 self.ltx_cache.evict_idle(),
3556 self.kokoro_cache.evict_idle(),
3557 ] {
3558 entries += n;
3559 bytes = bytes.saturating_add(b);
3560 }
3561 (entries, bytes)
3562 }
3563
3564 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3566 pub fn evict_idle_backends(&self) -> (usize, u64) {
3567 (0, 0)
3568 }
3569
3570 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3571 fn supports_native_mlx(schema: &ModelSchema) -> bool {
3572 matches!(schema.family.as_str(), "qwen3" | "qwen2.5-vl" | "qwen2-vl")
3573 }
3574
3575 fn catalog_registry_snapshot(&self) -> UnifiedRegistry {
3576 let mut registry = self.unified_registry.clone();
3577 registry.prune_missing_on_disk_models();
3578 registry.refresh_availability();
3582 self.filter_disabled_local_models(&mut registry);
3583 registry
3584 }
3585
3586 fn filter_disabled_local_models(&self, registry: &mut UnifiedRegistry) {
3587 let disabled = registry
3588 .all()
3589 .filter(|schema| {
3590 schema.downloads_weights()
3591 && !self
3592 .model_management
3593 .car_enabled(&schema.id)
3594 .unwrap_or(false)
3595 })
3596 .map(|schema| schema.id.clone())
3597 .collect::<Vec<_>>();
3598 for model_id in disabled {
3599 registry.unregister(&model_id);
3600 }
3601 }
3602
3603 async fn routing_registry_snapshot_with_credential_failure(
3604 &self,
3605 ) -> (UnifiedRegistry, Option<RouteCredentialFailure>) {
3606 let parslee = car_auth::resolve_credential(car_auth::CredentialReadMode::Use).await;
3612 let (parslee_api_base, parslee_signed_out, credential_failure) = match &parslee {
3613 Ok(Some(credential)) => (Some(credential.api_base.as_str()), false, None),
3614 Ok(None) => (None, true, Some(parslee_signed_out_route_failure())),
3615 Err(error) => {
3616 let source_error = error.to_string();
3617 (
3618 None,
3619 false,
3620 Some(RouteCredentialFailure {
3621 summary: format!(
3622 "Parslee {AUTH_STORE_UNREADABLE_MARKER} — unlock or grant access to the credential store, then retry"
3623 ),
3624 source_error,
3625 }),
3626 )
3627 }
3628 };
3629 let mut registry = self.unified_registry.clone();
3630 registry.prune_missing_on_disk_models();
3631 registry.refresh_routing_availability(parslee_api_base, parslee_signed_out);
3632 self.filter_disabled_local_models(&mut registry);
3633 (registry, credential_failure)
3634 }
3635
3636 async fn routing_registry_snapshot(&self) -> UnifiedRegistry {
3637 self.routing_registry_snapshot_with_credential_failure()
3638 .await
3639 .0
3640 }
3641
3642 async fn request_routing_registry_snapshot(
3647 &self,
3648 requested_model: Option<&str>,
3649 ) -> UnifiedRegistry {
3650 if requested_model.is_some() {
3651 self.catalog_registry_snapshot()
3652 } else {
3653 self.routing_registry_snapshot().await
3654 }
3655 }
3656
3657 pub async fn route_adaptive(&self, prompt: &str) -> AdaptiveRoutingDecision {
3659 self.route_adaptive_with_intent(prompt, None).await
3660 }
3661
3662 pub async fn route_adaptive_with_intent(
3672 &self,
3673 prompt: &str,
3674 intent: Option<crate::intent::IntentHint>,
3675 ) -> AdaptiveRoutingDecision {
3676 if let Some(model) = self.preferred_model_for_capability(ModelCapability::Generate) {
3677 let exclude_set = self
3678 .adaptive_router
3679 .build_exclude_set(intent.as_ref(), &self.unified_registry);
3680 let overridden_is_excluded =
3681 Self::model_is_excluded(&exclude_set, &self.unified_registry, model);
3682 if !overridden_is_excluded {
3683 let ctx_len = self
3689 .unified_registry
3690 .get(model)
3691 .or_else(|| self.unified_registry.find_by_name(model))
3692 .map(|s| s.context_length)
3693 .unwrap_or(0);
3694 return AdaptiveRoutingDecision {
3695 model_id: model.to_string(),
3696 model_name: model.to_string(),
3697 task: InferenceTask::Generate,
3698 complexity: TaskComplexity::assess(prompt),
3699 reason: "preferred generation model override".into(),
3700 strategy: RoutingStrategy::Explicit,
3701 predicted_quality: 0.5,
3702 fallbacks: vec![],
3703 context_length: ctx_len,
3704 needs_compaction: false,
3705 candidates: vec![],
3706 };
3707 }
3708 }
3709 let routing_registry = self.routing_registry_snapshot().await;
3710 let tracker = self.outcome_tracker.read().await;
3711 match intent {
3712 Some(hint) => self
3713 .adaptive_router
3714 .route_with(crate::adaptive_router::RouteRequest {
3715 intent: Some(&hint),
3716 ..crate::adaptive_router::RouteRequest::new(prompt, &routing_registry, &tracker)
3717 }),
3718 None => self
3719 .adaptive_router
3720 .route(prompt, &routing_registry, &tracker),
3721 }
3722 }
3723
3724 pub fn route(&self, prompt: &str) -> RoutingDecision {
3726 self.router.route_generate(prompt, &self.registry)
3727 }
3728
3729 pub fn estimated_tokens(
3740 &self,
3741 req: &GenerateRequest,
3742 model_id: Option<&str>,
3743 ) -> (usize, usize, bool) {
3744 let prompt_tokens = remote::estimate_tokens(&req.prompt);
3745 let context_tokens = req
3746 .context
3747 .as_ref()
3748 .map(|c| remote::estimate_tokens(c))
3749 .unwrap_or(0);
3750 let tools_tokens = req
3751 .tools
3752 .as_ref()
3753 .map(|t| remote::estimate_tokens(&serde_json::to_string(t).unwrap_or_default()))
3754 .unwrap_or(0);
3755 let media_tokens = media_tokens::request_media_and_history_tokens(
3756 req.images.as_deref(),
3757 req.messages.as_deref(),
3758 );
3759 let total_input = prompt_tokens + context_tokens + tools_tokens + media_tokens;
3760
3761 let context_window = match model_id {
3770 Some(id) => {
3771 let routing_registry = self.catalog_registry_snapshot();
3772 routing_registry
3773 .get(id)
3774 .or_else(|| routing_registry.find_by_name(id))
3775 .map(|s| s.context_length)
3776 .unwrap_or(0)
3777 }
3778 None => 0,
3779 };
3780
3781 let fits = context_window == 0 || (total_input + req.params.max_tokens) <= context_window;
3782 (total_input, context_window, fits)
3783 }
3784
3785 fn routing_cache_estimates(req: &GenerateRequest, estimated_input: usize) -> (usize, usize) {
3791 let read = req
3792 .params
3793 .estimated_cache_read_input_tokens
3794 .min(estimated_input);
3795 let write = req
3796 .params
3797 .estimated_cache_write_input_tokens
3798 .min(estimated_input.saturating_sub(read));
3799 (read, write)
3800 }
3801
3802 pub fn model_context_window(&self, model_id: &str) -> usize {
3808 let routing_registry = self.catalog_registry_snapshot();
3809 routing_registry
3810 .get(model_id)
3811 .or_else(|| routing_registry.find_by_name(model_id))
3812 .map(|s| s.context_length)
3813 .unwrap_or(0)
3814 }
3815
3816 pub async fn generate_tracked(
3831 &self,
3832 req: GenerateRequest,
3833 ) -> Result<InferenceResult, InferenceError> {
3834 let mut ignore_retry = |_: InferenceRetryProgress| {};
3835 self.generate_tracked_with_retry_observer(req, &mut ignore_retry)
3836 .await
3837 }
3838
3839 pub async fn generate_tracked_with_retry_observer(
3843 &self,
3844 req: GenerateRequest,
3845 retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
3846 ) -> Result<InferenceResult, InferenceError> {
3847 crate::offload::ensure_not_controlled_terminated()?;
3848 let catalog_snapshot = self
3849 .catalog_snapshot()
3850 .map_err(InferenceError::InferenceFailed)?;
3851 let recover = matches!(req.params.thinking, ThinkingMode::Auto);
3852 let mut result = self
3853 .generate_tracked_inner(req.clone(), &catalog_snapshot, retry_observer)
3854 .await?;
3855
3856 let action = classify_empty_pass(
3857 recover,
3858 result.stop_reason.as_deref(),
3859 &result.text,
3860 result.tool_calls.is_empty(),
3861 );
3862 let hit_decode_ceiling = action == EmptyPassAction::FailDecodeCeiling;
3863
3864 if action == EmptyPassAction::RetryWithoutThinking {
3865 result.stop_reason = Some("thinking_truncated".to_string());
3866 let mut retry = req;
3867 retry.params.thinking = ThinkingMode::Off;
3868 crate::offload::ensure_not_controlled_terminated()?;
3869 match self
3870 .generate_tracked_inner(retry, &catalog_snapshot, retry_observer)
3871 .await
3872 {
3873 Ok(mut recovered) => {
3874 if !recovered.text.trim().is_empty() || !recovered.tool_calls.is_empty() {
3875 recovered.stop_reason = Some("thinking_recovered".to_string());
3876 return Ok(recovered);
3877 }
3878 }
3879 Err(error @ InferenceError::ControlledTermination) => return Err(error),
3880 Err(_) => {}
3881 }
3882 }
3883
3884 if hit_decode_ceiling && result.text.trim().is_empty() && result.tool_calls.is_empty() {
3890 return Err(InferenceError::InferenceFailed(format!(
3891 "local generation hit its {}s wall-clock ceiling before producing any output. \
3892 The `local prefill starting` / `local decode in progress` log lines show where \
3893 the time went — a large prompt can spend most of it on prefill. Try a smaller \
3894 model, shorten the prompt, check for another process contending for the GPU, \
3895 or raise the ceiling with CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables it).",
3896 local_decode_timeout().map_or(0, |t| t.as_secs())
3897 )));
3898 }
3899
3900 Ok(result)
3901 }
3902
3903 #[instrument(
3904 name = "inference.generate",
3905 skip_all,
3906 fields(
3907 model = tracing::field::Empty,
3908 max_tokens = req.params.max_tokens,
3909 prompt_tokens = tracing::field::Empty,
3910 completion_tokens = tracing::field::Empty,
3911 latency_ms = tracing::field::Empty,
3912 )
3913 )]
3914 async fn generate_tracked_inner(
3915 &self,
3916 mut req: GenerateRequest,
3917 catalog_snapshot: &CatalogSnapshot,
3918 retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
3919 ) -> Result<InferenceResult, InferenceError> {
3920 crate::offload::ensure_not_controlled_terminated()?;
3921 validate_expected_catalog_revision(&req, catalog_snapshot)?;
3922 let requested_model_id = exact_pinned_model_id(&req).map(str::to_string);
3923 if let Some(model_id) = requested_model_id.as_ref() {
3924 req.model = Some(model_id.clone());
3925 req.params.strict_model = true;
3926 }
3927 let start = Instant::now();
3928 let has_requested_route = req.model.is_some();
3929 let (routing_registry, initial_route_credential_failure) = if has_requested_route {
3930 (self.catalog_registry_snapshot(), None)
3931 } else {
3932 self.routing_registry_snapshot_with_credential_failure()
3933 .await
3934 };
3935 if let Some(requested) = requested_model_id.as_deref() {
3936 if routing_registry.get(requested).is_none() {
3937 return Err(InferenceError::ModelNotFound(requested.to_string()));
3938 }
3939 } else if let Some(requested) = req.model.as_deref() {
3940 if routing_registry
3941 .get(requested)
3942 .or_else(|| routing_registry.find_by_name(requested))
3943 .is_none()
3944 {
3945 return Err(InferenceError::ModelNotFound(requested.to_string()));
3946 }
3947 }
3948
3949 let (estimated_input, _, _) = self.estimated_tokens(&req, None);
3951 let estimated_footprint = estimated_input.saturating_add(req.params.max_tokens);
3959 let (estimated_cache_read, estimated_cache_write) =
3960 Self::routing_cache_estimates(&req, estimated_input);
3961 let tracker_read = self.outcome_tracker.read().await;
3962 let has_tools = Self::request_has_tools(&req);
3963 let has_vision = Self::request_needs_vision(&req);
3964 let preferred_model = self
3965 .preferred_model_for_capability(ModelCapability::Generate)
3966 .map(str::to_string);
3967 let exclude_set = self
3968 .adaptive_router
3969 .build_exclude_set(req.intent.as_ref(), &routing_registry);
3970 let unpinned_override = self
3971 .lane_pin_for(&req, &routing_registry)
3972 .or(preferred_model)
3973 .filter(|model| !Self::model_is_excluded(&exclude_set, &routing_registry, model));
3974 let decision = match req.model.clone().or(unpinned_override) {
3975 Some(m) => {
3976 let ctx_len = routing_registry
3977 .get(&m)
3978 .or_else(|| routing_registry.find_by_name(&m))
3979 .map(|s| s.context_length)
3980 .unwrap_or(0);
3981 AdaptiveRoutingDecision {
3982 model_id: m.clone(),
3983 model_name: m.clone(),
3984 task: InferenceTask::Generate,
3985 complexity: TaskComplexity::assess(&req.prompt),
3986 reason: "explicit model".into(),
3987 strategy: RoutingStrategy::Explicit,
3988 predicted_quality: 0.5,
3989 fallbacks: vec![],
3990 context_length: ctx_len,
3991 needs_compaction: ctx_len > 0 && estimated_footprint > ctx_len,
3992 candidates: vec![],
3993 }
3994 }
3995 None => self
3996 .adaptive_router
3997 .route_with(crate::adaptive_router::RouteRequest {
3998 estimated_total_tokens: estimated_footprint,
3999 estimated_input_tokens: estimated_input,
4000 estimated_output_tokens: req.params.max_tokens,
4001 estimated_cache_read_tokens: estimated_cache_read,
4002 estimated_cache_write_tokens: estimated_cache_write,
4003 has_tools,
4004 has_vision,
4005 workload: req.params.workload,
4006 intent: req.intent.as_ref(),
4007 ..crate::adaptive_router::RouteRequest::new(
4008 &req.prompt,
4009 &routing_registry,
4010 &tracker_read,
4011 )
4012 }),
4013 };
4014 drop(tracker_read);
4015
4016 if decision.model_id.is_empty() {
4017 let excluded_models = req
4018 .intent
4019 .as_ref()
4020 .map(|hint| hint.exclude_models.join(", "))
4021 .unwrap_or_default();
4022 return Err(InferenceError::NoEligibleModel { excluded_models });
4023 }
4024
4025 if decision.needs_compaction {
4026 tracing::info!(
4027 model = %decision.model_name,
4028 prompt_tokens = estimated_input,
4029 context_window = decision.context_length,
4030 "prompt exceeds model context window — compaction or truncation needed"
4031 );
4032 }
4033
4034 debug!(
4039 model = %decision.model_name,
4040 strategy = ?decision.strategy,
4041 reason = %decision.reason,
4042 "adaptive-routed generate request"
4043 );
4044
4045 let mut req = req;
4048
4049 if let Some(schema) = routing_registry
4055 .get(&decision.model_id)
4056 .or_else(|| routing_registry.find_by_name(&decision.model_id))
4057 {
4058 req.params.max_tokens = resolved_max_tokens(req.params.max_tokens, schema);
4059 }
4060
4061 let is_code_intent = is_explicit_code_intent(req.intent.as_ref());
4070 let is_complex = matches!(decision.complexity, TaskComplexity::Complex);
4071 if req.params.budget_tokens == 0 && (is_code_intent || is_complex) {
4072 let supports_thinking = routing_registry
4076 .get(&decision.model_id)
4077 .or_else(|| routing_registry.find_by_name(&decision.model_id))
4078 .map(|s| {
4079 s.supported_params
4080 .contains(&schema::GenerateParam::ExtendedThinking)
4081 })
4082 .unwrap_or(false);
4083 if let Some(budget) =
4084 auto_thinking_budget(is_code_intent, is_complex, supports_thinking)
4085 {
4086 req.params.budget_tokens = budget;
4087 tracing::info!(
4088 model = %decision.model_name,
4089 budget,
4090 code_intent = is_code_intent,
4091 "auto-enabled extended thinking"
4092 );
4093 }
4094 }
4095
4096 let mut models_to_try = vec![decision.model_id.clone()];
4098 models_to_try.extend(decision.fallbacks.iter().cloned());
4099
4100 let chain_has_local = models_to_try.iter().any(|m| {
4115 routing_registry
4116 .get(m)
4117 .or_else(|| routing_registry.find_by_name(m))
4118 .map(|s| s.is_local())
4119 .unwrap_or(false)
4120 });
4121 let mut local_last_resort_id = None;
4122 if should_append_local_last_resort(chain_has_local, req.params.strict_model) {
4123 if let Some(local) = self.first_installed_local_model(has_tools) {
4127 tracing::info!(
4128 local_model = %local,
4129 needs_tools = has_tools,
4130 "appended on-device model as last-resort fallback (chain was remote-only)"
4131 );
4132 models_to_try.push(local.clone());
4133 local_last_resort_id = Some(local);
4134 }
4135 } else if !chain_has_local && req.params.strict_model {
4136 tracing::info!(
4137 model = %decision.model_id,
4138 "strict_model set — not degrading to on-device; a remote failure will surface as an error"
4139 );
4140 }
4141
4142 let mut last_error = None;
4143 let mut auth_dead_lane: Option<String> = None;
4149 let mut route_credential_failure = if chain_includes_parslee_route(
4159 |m| {
4160 routing_registry
4161 .get(m)
4162 .or_else(|| routing_registry.find_by_name(m))
4163 },
4164 &models_to_try,
4165 ) {
4166 initial_route_credential_failure
4167 } else {
4168 None
4169 };
4170 let mut fallback_hops: Vec<FallbackFrom> = Vec::new();
4174
4175 let mut candidate_queue: std::collections::VecDeque<String> =
4182 models_to_try.iter().cloned().collect();
4183 let mut is_primary_attempt = true;
4184 while let Some(candidate_owned) = candidate_queue.pop_front() {
4185 crate::offload::ensure_not_controlled_terminated()?;
4186 let was_primary = is_primary_attempt;
4187 is_primary_attempt = false;
4188 let candidate_id = &candidate_owned;
4189 #[allow(unused_mut)]
4192 let mut schema = routing_registry
4193 .get(candidate_id)
4194 .or_else(|| routing_registry.find_by_name(candidate_id))
4195 .cloned();
4196
4197 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4205 if requested_model_id.is_none() {
4206 if let Some(ref s) = schema {
4207 if let Some(mlx_equiv) = routing_registry.resolve_mlx_equivalent(s) {
4208 tracing::info!(
4209 from = %s.id, to = %mlx_equiv.id,
4210 "redirecting GGUF model to MLX equivalent on Apple Silicon"
4211 );
4212 schema = Some(mlx_equiv.clone());
4213 }
4214 }
4215 }
4216
4217 let candidate_name = schema
4222 .as_ref()
4223 .map(|s| s.name.clone())
4224 .unwrap_or_else(|| candidate_id.clone());
4225
4226 if has_tools
4237 && schema
4238 .as_ref()
4239 .map(|s| !s.has_capability(ModelCapability::ToolUse))
4240 .unwrap_or(false)
4241 {
4242 let backend = schema
4243 .as_ref()
4244 .map(|s| if s.is_local() { "local" } else { "remote" })
4245 .unwrap_or("unknown");
4246 tracing::warn!(
4247 model = %candidate_id,
4248 backend,
4249 "tools requested but resolved model lacks ToolUse capability — skipping candidate"
4250 );
4251 let unsupported = InferenceError::UnsupportedMode {
4252 mode: "tool_use",
4253 backend,
4254 reason: "resolved model does not support structured tool calls; configure a tool-capable model (a remote API model, or run the vllm-mlx OpenAI-compatible server)",
4255 };
4256 record_fallback_from(&mut fallback_hops, &candidate_name, &unsupported);
4262 last_error = Some(unsupported);
4263 continue;
4264 }
4265
4266 let resolved_id = schema
4277 .as_ref()
4278 .map(|s| s.id.clone())
4279 .unwrap_or_else(|| candidate_id.clone());
4280 let reported_model_used = if requested_model_id.is_some() {
4281 resolved_id.clone()
4282 } else {
4283 candidate_name.clone()
4284 };
4285 validate_expected_catalog_row(&req, catalog_snapshot, &resolved_id)?;
4286
4287 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4293 if requested_model_id.is_some()
4294 && schema
4295 .as_ref()
4296 .is_some_and(|schema| matches!(schema.source, ModelSource::Local { .. }))
4297 {
4298 let pinned = InferenceError::InferenceFailed(format!(
4299 "exact model_id `{resolved_id}` names a GGUF/Candle row, but Candle is disabled on Apple Silicon; MLX-equivalent substitution is disabled for exact pins"
4300 ));
4301 record_fallback_from(&mut fallback_hops, &candidate_name, &pinned);
4302 last_error = Some(pinned);
4303 continue;
4304 }
4305
4306 let is_remote = schema
4307 .as_ref()
4308 .map(|s| s.is_remote() || s.is_vllm_mlx())
4309 .unwrap_or(false);
4310 let is_codex_cli = schema.as_ref().map(|s| s.is_codex_cli()).unwrap_or(false);
4311 let is_delegated = schema.as_ref().map(|s| s.is_delegated()).unwrap_or(false);
4312
4313 let attempt_trace = {
4318 let mut tracker = self.outcome_tracker.write().await;
4319 tracker.record_start(&resolved_id, decision.task, &decision.reason)
4320 };
4321
4322 if is_delegated {
4328 let runner = match runner::current_inference_runner() {
4329 Some(r) => r,
4330 None => {
4331 let msg = "model declares ModelSource::Delegated but no inference runner is registered";
4332 self.outcome_tracker
4333 .write()
4334 .await
4335 .record_failure(&attempt_trace, msg);
4336 let unregistered = InferenceError::InferenceFailed(msg.into());
4337 record_fallback_from(&mut fallback_hops, &candidate_name, &unregistered);
4338 last_error = Some(unregistered);
4339 continue;
4340 }
4341 };
4342 let (tx, mut rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
4343 let emitter = runner::EventEmitter::new(tx);
4344 let runner_req = req.clone();
4345 let runner_handle = AbortOnDropTask(Some(tokio::spawn(async move {
4346 runner.run(runner_req, emitter).await
4347 })));
4348 let mut accumulator = stream::StreamAccumulator::default();
4349 while let Some(evt) = rx.recv().await {
4350 accumulator.push(&evt);
4351 }
4352 let (acc_text, acc_tool_calls, acc_usage, acc_stop_reason) =
4381 accumulator.finish_with_usage();
4382 match runner_handle.join().await {
4383 Ok(Ok(runner_result)) => {
4384 let elapsed = start.elapsed().as_millis() as u64;
4385 let acc_text = if acc_text.trim().is_empty() {
4386 runner_result.text
4387 } else {
4388 acc_text
4389 };
4390 let acc_tool_calls = if acc_tool_calls.is_empty() {
4391 runner_result.tool_calls
4392 } else {
4393 acc_tool_calls
4394 };
4395 let est_out = acc_text.split_whitespace().count();
4402 {
4403 let mut tracker = self.outcome_tracker.write().await;
4404 tracker.record_complete(
4405 &attempt_trace,
4406 elapsed,
4407 estimated_input,
4408 est_out,
4409 );
4410 }
4411 let local_last_resort = report_local_last_resort_served(
4412 local_last_resort_id.as_deref(),
4413 candidate_id,
4414 &resolved_id,
4415 );
4416 return Ok(InferenceResult {
4417 text: acc_text,
4418 tool_calls: acc_tool_calls,
4419 bounding_boxes: vec![],
4420 trace_id: attempt_trace,
4421 model_used: reported_model_used,
4422 model_identity: bound_model_identity(
4423 catalog_snapshot,
4424 requested_model_id.as_deref(),
4425 &resolved_id,
4426 )?,
4427 latency_ms: elapsed,
4428 time_to_first_token_ms: None,
4429 usage: acc_usage,
4432 provider_output_items: vec![],
4433 thinking: vec![],
4436 stop_reason: acc_stop_reason,
4437 auth_fallback_from: auth_dead_lane.clone(),
4438 local_last_resort,
4439 fallback_from: fallback_hops.clone(),
4440 });
4441 }
4442 Ok(Err(e)) => {
4443 self.outcome_tracker
4444 .write()
4445 .await
4446 .record_failure(&attempt_trace, &e.to_string());
4447 let failed = InferenceError::InferenceFailed(e.to_string());
4454 record_auth_dead_lane(
4455 &mut auth_dead_lane,
4456 &candidate_name,
4457 &failed.to_string(),
4458 );
4459 record_route_credential_failure(
4460 &mut route_credential_failure,
4461 &candidate_name,
4462 &failed,
4463 was_primary && has_requested_route,
4464 );
4465 record_fallback_from(&mut fallback_hops, &candidate_name, &failed);
4466 last_error = Some(failed);
4467 continue;
4468 }
4469 Err(join_err) => {
4470 let msg = format!("runner task panicked: {join_err}");
4471 self.outcome_tracker
4472 .write()
4473 .await
4474 .record_failure(&attempt_trace, &msg);
4475 let failed = InferenceError::InferenceFailed(msg);
4481 record_fallback_from(&mut fallback_hops, &candidate_name, &failed);
4482 last_error = Some(failed);
4483 continue;
4484 }
4485 }
4486 }
4487
4488 let has_tools = Self::request_has_tools(&req);
4489
4490 let context = if has_tools
4492 && req.tools.as_ref().is_some_and(|t| {
4493 t.iter().any(|tool| {
4494 tool.get("function")
4495 .and_then(|f| f.get("name"))
4496 .and_then(|n| n.as_str())
4497 == Some("done")
4498 })
4499 }) {
4500 let base = req.context.as_deref().unwrap_or("");
4501 Some(format!(
4502 "{base}\n\nIMPORTANT: When calling the `done` tool, the `result` field MUST contain a DETAILED summary of everything you found and did. This is the ONLY output the user sees. Do NOT just say 'completed' — include specific findings, data, and conclusions."
4503 ))
4504 } else {
4505 req.context.clone()
4506 };
4507
4508 let mut captured_thinking: Vec<crate::tasks::generate::ThinkingBlock> = Vec::new();
4512 let mut captured_provider_output_items: Vec<serde_json::Value> = Vec::new();
4513 let mut local_reservation = if !is_delegated {
4519 match schema
4520 .as_ref()
4521 .filter(|schema| Self::reserve_in_outer_dispatch(schema))
4522 {
4523 Some(local_schema) => {
4524 match self
4525 .reserve_local_request_with_worker_retry(
4526 local_schema,
4527 estimated_footprint,
4528 )
4529 .await
4530 {
4531 Ok(reservation) => Some(reservation),
4532 Err(error) if req.model.is_some() || req.params.strict_model => {
4533 self.outcome_tracker
4534 .write()
4535 .await
4536 .record_capability_rejection(
4537 &attempt_trace,
4538 &error.to_string(),
4539 );
4540 return Err(error);
4541 }
4542 Err(error) => {
4543 tracing::warn!(
4544 model = %local_schema.id,
4545 error = %error,
4546 "adaptive local candidate blocked by resource policy; trying next route"
4547 );
4548 self.outcome_tracker
4549 .write()
4550 .await
4551 .record_capability_rejection(
4552 &attempt_trace,
4553 &error.to_string(),
4554 );
4555 record_fallback_from(&mut fallback_hops, &candidate_name, &error);
4560 last_error = Some(error);
4561 continue;
4562 }
4563 }
4564 }
4565 None => None,
4566 }
4567 } else {
4568 None
4569 };
4570 let result = if is_codex_cli {
4571 let schema_ref = schema
4572 .as_ref()
4573 .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4574 if req.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
4575 Err(InferenceError::UnsupportedMode {
4576 mode: "tools",
4577 backend: "codex-cli",
4578 reason: "the subscription-backed Codex source is a side-effect-free text generator and does not accept tools",
4579 })
4580 } else if req
4581 .messages
4582 .as_ref()
4583 .is_some_and(|messages| !messages.is_empty())
4584 {
4585 Err(InferenceError::UnsupportedMode {
4586 mode: "multi-turn-messages",
4587 backend: "codex-cli",
4588 reason: "the subscription-backed Codex source accepts one prompt plus optional context; it does not resume or replay conversations",
4589 })
4590 } else if req.images.as_ref().is_some_and(|images| !images.is_empty())
4591 || Self::request_has_video(&req)
4592 || Self::request_has_audio(&req)
4593 {
4594 Err(InferenceError::UnsupportedMode {
4595 mode: "multimodal-content",
4596 backend: "codex-cli",
4597 reason: "the subscription-backed Codex source is text-only",
4598 })
4599 } else if req.response_format.is_some() {
4600 Err(InferenceError::UnsupportedMode {
4601 mode: "response-format",
4602 backend: "codex-cli",
4603 reason: "the subscription-backed Codex source returns plain text and does not expose provider-enforced schemas",
4604 })
4605 } else {
4606 let model = match &schema_ref.source {
4607 ModelSource::CodexCli { model } => model,
4608 _ => unreachable!("is_codex_cli matched a different source"),
4609 };
4610 crate::backend::codex_cli::generate(
4611 model,
4612 &req.prompt,
4613 context.as_deref(),
4614 req.params.max_tokens,
4615 schema_ref.context_length,
4616 )
4617 .await
4618 .map(|output| (output.text, vec![], Some(output.usage), None, None))
4619 }
4620 } else if is_remote {
4621 let (schema_val, _remote_request_reservation) = match self
4625 .vllm_live_schema(
4626 schema.unwrap(),
4627 local_reservation.take(),
4628 estimated_footprint,
4629 )
4630 .await
4631 {
4632 Ok(pair) => pair,
4633 Err(e) => {
4634 self.outcome_tracker
4635 .write()
4636 .await
4637 .record_failure(&attempt_trace, &e.to_string());
4638 record_fallback_from(&mut fallback_hops, &candidate_name, &e);
4639 last_error = Some(e);
4640 continue;
4641 }
4642 };
4643 let _ctx_len = schema_val.context_length;
4644 let temperature = if !schema_val.supported_params.is_empty()
4647 && !schema_val
4648 .supported_params
4649 .contains(&crate::schema::GenerateParam::Temperature)
4650 {
4651 -1.0
4652 } else {
4653 req.params.temperature
4654 };
4655
4656 self.remote_backend
4662 .generate_with_tools_multi_observed(
4663 &schema_val,
4664 &req.prompt,
4665 context.as_deref(),
4666 temperature,
4667 req.params.max_tokens,
4668 req.tools.as_deref(),
4669 req.images.as_deref(),
4670 req.messages.as_deref(),
4671 req.params.tool_choice.as_deref(),
4672 req.params.parallel_tool_calls,
4673 req.params.budget_tokens,
4674 req.cache_control,
4675 req.params.cache_ttl,
4676 req.context_stable_prefix.as_deref(),
4677 req.response_format.as_ref(),
4678 retry_observer,
4679 )
4680 .await
4681 .map(|(t, c, thinking, provider_items, u, stop)| {
4687 captured_thinking = thinking;
4688 captured_provider_output_items = provider_items;
4689 (t, c, u, None::<u64>, stop)
4690 })
4691 } else if let Some(offload) = schema
4692 .as_ref()
4693 .filter(|schema| Self::supports_worker_offload(schema))
4694 .and_then(|_| crate::offload::current_local_offload())
4695 {
4696 let mut offload_req = req.clone();
4707 offload_req.model = Some(resolved_id.clone());
4708 let schema_ref = schema
4709 .as_ref()
4710 .ok_or_else(|| InferenceError::ModelNotFound(resolved_id.clone()))?;
4711 let reservation = local_reservation.as_mut().ok_or_else(|| {
4712 InferenceError::InferenceFailed(
4713 "local worker dispatch missing admission reservation".into(),
4714 )
4715 })?;
4716 if let Some(allocation_id) = offload.resident_allocation_id(&resolved_id) {
4717 reservation.bind_allocation_id(&allocation_id);
4723 }
4724 let admission = self.prepare_worker_admission(schema_ref, reservation)?;
4725 match offload.generate_admitted(offload_req, admission).await {
4726 Ok(outcome) => {
4727 Self::reconcile_worker_residency(
4728 offload.as_ref(),
4729 &resolved_id,
4730 &outcome.residency,
4731 outcome.retention,
4732 reservation,
4733 )
4734 .await?;
4735 let ir = outcome.result;
4736 captured_thinking = ir.thinking;
4743 captured_provider_output_items = ir.provider_output_items;
4744 Ok((
4745 ir.text,
4746 ir.tool_calls,
4747 ir.usage,
4748 ir.time_to_first_token_ms,
4749 ir.stop_reason,
4750 ))
4751 }
4752 Err(e) => Err(e),
4753 }
4754 } else {
4755 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4756 {
4757 let schema_ref = schema
4761 .as_ref()
4762 .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4763
4764 if schema_ref.is_foundation_models() {
4772 let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
4773 if Self::request_has_video(&req) || Self::request_has_audio(&req) {
4774 Err(InferenceError::UnsupportedMode {
4775 mode: "multimodal-content",
4776 backend: "foundation-models",
4777 reason: "the FoundationModels bridge exposes text and image \
4778 input — route audio/video to a remote VL model",
4779 })
4780 } else if has_images {
4781 let images: Vec<String> = req
4787 .images
4788 .as_ref()
4789 .map(|blocks| {
4790 blocks
4791 .iter()
4792 .filter_map(|b| match b {
4793 crate::ContentBlock::ImageBase64 { data, .. } => {
4794 Some(data.clone())
4795 }
4796 _ => None,
4800 })
4801 .collect()
4802 })
4803 .unwrap_or_default();
4804 if images.is_empty() {
4805 return Err(InferenceError::UnsupportedMode {
4806 mode: "multimodal-content",
4807 backend: "foundation-models",
4808 reason: "FoundationModels takes inline image bytes; \
4809 image URLs must be fetched by the caller first",
4810 });
4811 }
4812 let prompt = req.prompt.clone();
4813 let instructions = context.clone();
4814 let fm_prompt = prompt.clone();
4815 let fm_instructions = instructions.clone();
4816 let fm_ctx_window = schema_ref.context_length as u64;
4817 let max_tokens = req.params.max_tokens as u32;
4818 let temperature = req.params.temperature;
4819 tokio::task::spawn_blocking(move || {
4820 crate::backend::foundation_models::generate_with_images(
4821 &prompt,
4822 instructions.as_deref(),
4823 &images,
4824 max_tokens,
4825 temperature as f32,
4826 )
4827 })
4828 .await
4829 .map_err(|e| {
4830 InferenceError::InferenceFailed(format!(
4831 "FoundationModels task panicked: {e}"
4832 ))
4833 })
4834 .and_then(|r| r)
4835 .map(|text| {
4836 let usage = foundation_models_usage(
4837 fm_instructions.as_deref(),
4838 &fm_prompt,
4839 &text,
4840 fm_ctx_window,
4841 );
4842 (text, vec![], usage, None, None)
4843 })
4844 } else if has_tools {
4845 if req.response_format.is_some() {
4851 tracing::warn!(
4852 "FoundationModels: response_format is ignored when tools \
4853 are present — one turn is either tool-enabled or \
4854 schema-constrained"
4855 );
4856 }
4857 let prompt = req.prompt.clone();
4858 let instructions = context.clone();
4859 let fm_prompt = prompt.clone();
4862 let fm_instructions = instructions.clone();
4863 let fm_ctx_window = schema_ref.context_length as u64;
4864 let tools_defs = req.tools.clone().unwrap_or_default();
4865 let fm_tool_choice = req.params.tool_choice.clone();
4866 let max_tokens = req.params.max_tokens as u32;
4867 let temperature = req.params.temperature;
4868 tokio::task::spawn_blocking(move || {
4869 crate::backend::foundation_models::generate_with_tools(
4870 &prompt,
4871 instructions.as_deref(),
4872 &tools_defs,
4873 fm_tool_choice.as_deref(),
4874 max_tokens,
4875 temperature as f32,
4876 )
4877 })
4878 .await
4879 .map_err(|e| {
4880 InferenceError::InferenceFailed(format!(
4881 "FoundationModels task panicked: {e}"
4882 ))
4883 })
4884 .and_then(|r| r)
4885 .map(|(text, calls)| {
4886 let usage = foundation_models_usage(
4887 fm_instructions.as_deref(),
4888 &fm_prompt,
4889 &text,
4890 fm_ctx_window,
4891 );
4892 (text, calls, usage, None, None)
4893 })
4894 } else if let Some(crate::tasks::generate::ResponseFormat::JsonSchema {
4895 schema,
4896 ..
4897 }) = &req.response_format
4898 {
4899 let prompt = req.prompt.clone();
4903 let instructions = context.clone();
4904 let fm_prompt = prompt.clone();
4907 let fm_instructions = instructions.clone();
4908 let fm_ctx_window = schema_ref.context_length as u64;
4909 let schema_val = schema.clone();
4910 let max_tokens = req.params.max_tokens as u32;
4911 let temperature = req.params.temperature;
4912 tokio::task::spawn_blocking(move || {
4913 crate::backend::foundation_models::generate_structured(
4914 &prompt,
4915 instructions.as_deref(),
4916 &schema_val,
4917 max_tokens,
4918 temperature as f32,
4919 )
4920 })
4921 .await
4922 .map_err(|e| {
4923 InferenceError::InferenceFailed(format!(
4924 "FoundationModels task panicked: {e}"
4925 ))
4926 })
4927 .and_then(|r| r)
4928 .map(|text| {
4929 let usage = foundation_models_usage(
4930 fm_instructions.as_deref(),
4931 &fm_prompt,
4932 &text,
4933 fm_ctx_window,
4934 );
4935 (text, vec![], usage, None, None)
4936 })
4937 } else {
4938 let instructions = if matches!(
4942 req.response_format,
4943 Some(crate::tasks::generate::ResponseFormat::JsonObject)
4944 ) {
4945 tracing::warn!(
4946 "FoundationModels: JsonObject response_format has no native \
4947 constrained mode — enforcing via instruction injection"
4948 );
4949 let base = context.clone().unwrap_or_default();
4950 Some(format!(
4951 "{base}\n\nRespond with a single valid JSON object and \
4952 nothing else."
4953 ))
4954 } else {
4955 context.clone()
4956 };
4957 let prompt = req.prompt.clone();
4958 let fm_prompt = prompt.clone();
4961 let fm_instructions = instructions.clone();
4962 let fm_ctx_window = schema_ref.context_length as u64;
4963 let max_tokens = req.params.max_tokens as u32;
4964 let temperature = req.params.temperature;
4965 tokio::task::spawn_blocking(move || {
4966 crate::backend::foundation_models::generate(
4967 &prompt,
4968 instructions.as_deref(),
4969 max_tokens,
4970 temperature as f32,
4971 )
4972 })
4973 .await
4974 .map_err(|e| {
4975 InferenceError::InferenceFailed(format!(
4976 "FoundationModels task panicked: {e}"
4977 ))
4978 })
4979 .and_then(|r| r)
4980 .map(|text| {
4981 let usage = foundation_models_usage(
4982 fm_instructions.as_deref(),
4983 &fm_prompt,
4984 &text,
4985 fm_ctx_window,
4986 );
4987 (text, vec![], usage, None, None)
4988 })
4989 }
4990 } else if !schema_ref.is_mlx() {
4991 Err(InferenceError::InferenceFailed(format!(
4992 "model '{}' has no MLX equivalent; Candle backend disabled on Apple Silicon",
4993 schema_ref.id
4994 )))
4995 } else if schema_ref.tags.iter().any(|t| t == "mlx-vlm-cli") {
4996 let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
5008 if !has_images {
5009 return Err(InferenceError::UnsupportedMode {
5010 mode: "text-only-on-mlx-vlm-id",
5011 backend: "mlx-vlm-cli",
5012 reason: "the `mlx-vlm/...` model IDs route exclusively \
5013 through the mlx-vlm CLI for image inference. \
5014 For text-only generation, route to a Qwen3 \
5015 text model (`mlx/qwen3-4b:4bit` etc.) — the \
5016 CLI shell-out has higher latency than the \
5017 in-process MLX text tower.",
5018 });
5019 }
5020 let vlm_status = crate::backend::mlx_vlm_cli::runtime_status();
5021 if !vlm_status.is_available() {
5022 return Err(InferenceError::InferenceFailed(vlm_status.user_message()));
5023 }
5024 let model_dir = self.unified_registry.ensure_local(&schema_ref.id).await?;
5025 let reservation = local_reservation
5026 .as_mut()
5027 .expect("local VLM branch has admission reservation");
5028 Self::reconcile_transient_local_allocation(
5029 reservation,
5030 backend_cache::estimate_model_size(&model_dir),
5031 )?;
5032 let detached_lease = reservation.detached_lease();
5033 let repo = match &schema_ref.source {
5034 crate::schema::ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
5035 _ => {
5036 return Err(InferenceError::InferenceFailed(format!(
5037 "model '{}' is tagged mlx-vlm-cli but its \
5038 source isn't ModelSource::Mlx — registry bug",
5039 schema_ref.id
5040 )));
5041 }
5042 };
5043 let imgs = req.images.clone().unwrap_or_default();
5044 let temp = req.params.temperature;
5045 let max_t = req.params.max_tokens;
5046 let prompt = req.prompt.clone();
5047 let (text, cli_usage) = run_admitted_blocking(detached_lease, move || {
5048 crate::backend::mlx_vlm_cli::generate(
5049 &repo, &prompt, &imgs, temp, max_t,
5050 )
5051 })
5052 .await
5053 .map_err(|e| {
5054 InferenceError::InferenceFailed(format!(
5055 "mlx_vlm CLI task panicked: {e}"
5056 ))
5057 })??;
5058 let bounding_boxes = parse_boxes(&text);
5059 let latency_ms = start.elapsed().as_millis() as u64;
5060 let usage = cli_usage.map(|u| TokenUsage {
5070 prompt_tokens: u.prompt_tokens,
5071 completion_tokens: u.completion_tokens,
5072 total_tokens: u.prompt_tokens + u.completion_tokens,
5073 context_window: schema_ref.context_length as u64,
5074 ..Default::default()
5076 });
5077 {
5078 let (in_tokens, out_tokens) = match &usage {
5084 Some(u) => (u.prompt_tokens as usize, u.completion_tokens as usize),
5085 None => (estimated_input, text.split_whitespace().count()),
5086 };
5087 let mut tracker = self.outcome_tracker.write().await;
5088 tracker.record_complete(
5089 &attempt_trace,
5090 latency_ms,
5091 in_tokens,
5092 out_tokens,
5093 );
5094 }
5095 let local_last_resort = report_local_last_resort_served(
5096 local_last_resort_id.as_deref(),
5097 candidate_id,
5098 &resolved_id,
5099 );
5100 return Ok(InferenceResult {
5101 text,
5102 tool_calls: vec![],
5103 bounding_boxes,
5104 trace_id: attempt_trace,
5105 model_used: schema_ref.id.clone(),
5106 model_identity: bound_model_identity(
5107 catalog_snapshot,
5108 requested_model_id.as_deref(),
5109 &resolved_id,
5110 )?,
5111 latency_ms,
5112 time_to_first_token_ms: None,
5113 usage,
5114 provider_output_items: Vec::new(),
5115 thinking: Vec::new(), stop_reason: None,
5117 auth_fallback_from: auth_dead_lane.clone(),
5118 local_last_resort,
5119 fallback_from: fallback_hops.clone(),
5120 });
5121 } else if !Self::supports_native_mlx(schema_ref) {
5122 if req.images.as_ref().is_some_and(|i| !i.is_empty())
5129 || Self::request_has_video(&req)
5130 || Self::request_has_audio(&req)
5131 {
5132 return Err(InferenceError::UnsupportedMode {
5133 mode: "multimodal-content-block",
5134 backend: "native-mlx-local",
5135 reason: "this in-process MLX backend is text-only; route \
5136 image/video/audio understanding to a vLLM-MLX or remote \
5137 multimodal model",
5138 });
5139 }
5140 self.generate_local(
5151 req.clone(),
5152 &schema_ref.id,
5153 local_reservation
5154 .as_mut()
5155 .expect("local branch has admission reservation"),
5156 )
5157 .await
5158 } else {
5159 if Self::request_has_video(&req) {
5165 return Err(InferenceError::UnsupportedMode {
5166 mode: "video-content-block",
5167 backend: "native-mlx-qwen25vl",
5168 reason: "Qwen2.5-VL video understanding is on the request surface \
5169 but the video-tokenization path (frame sampling + merger) \
5170 is not yet wired; route to a remote VL provider for now",
5171 });
5172 }
5173 if Self::request_has_audio(&req) {
5174 return Err(InferenceError::UnsupportedMode {
5175 mode: "audio-content-block",
5176 backend: "native-mlx-qwen25vl",
5177 reason: "audio understanding is on the request surface (Gemma 4 \
5178 E2B/E4B and Gemini accept it) but the native MLX path \
5179 for this model does not — route to Gemini or Gemma-4",
5180 });
5181 }
5182 let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
5183 if has_images {
5184 let (handle, _retention) = self
5193 .ensure_mlx_backend(
5194 schema_ref,
5195 local_reservation
5196 .as_mut()
5197 .expect("local branch has admission reservation"),
5198 )
5199 .await?;
5200 let can_do_vision = {
5201 let guard = handle.lock().map_err(|_| {
5202 InferenceError::InferenceFailed(
5203 "MLX backend mutex poisoned".into(),
5204 )
5205 })?;
5206 guard.supports_capability(crate::schema::ModelCapability::Vision)
5207 };
5208 if !can_do_vision {
5209 return Err(InferenceError::UnsupportedMode {
5219 mode: "image-content-block",
5220 backend: "native-mlx-text",
5221 reason: "this MLX backend is a plain Qwen3 text tower. \
5222 For local image inference, route to \
5223 `mlx-vlm/qwen3-vl-2b:bf16` or another `mlx-vlm/...` \
5224 catalog ID so CAR shells out to `mlx_vlm.generate`. \
5225 Alternatives: a local vLLM-MLX VLM server, or a \
5226 remote VL model. (#115)",
5227 });
5228 }
5229 }
5230 self.generate_mlx(
5231 render_for_local_backend(req.clone()),
5232 &schema_ref.id,
5233 local_reservation
5234 .as_mut()
5235 .expect("local branch has admission reservation"),
5236 )
5237 .await
5238 .map(|(text, usage, ttft, stop)| (text, vec![], usage, ttft, stop))
5239 }
5240 }
5241
5242 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5243 {
5244 let schema_ref = schema
5245 .as_ref()
5246 .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
5247 match self
5248 .ensure_backend(
5249 schema_ref,
5250 local_reservation
5251 .as_mut()
5252 .expect("local branch has admission reservation"),
5253 )
5254 .await
5255 {
5256 Ok(()) => {
5257 let mut write = self.backend.write().await;
5258 let backend = write.get_mut(&schema_ref.id).ok_or_else(|| {
5259 InferenceError::InferenceFailed(format!(
5260 "candle backend missing after ensure_backend for {}",
5261 schema_ref.id
5262 ))
5263 })?;
5264 let ctx_window = backend.context_length().unwrap_or(0) as u64;
5270 tasks::generate::generate(
5271 backend,
5272 render_for_local_backend(req.clone()),
5273 )
5274 .await
5275 .map(
5276 |(text, ttft, prompt_tokens, completion_tokens)| {
5277 let usage = TokenUsage {
5278 prompt_tokens: prompt_tokens as u64,
5279 completion_tokens: completion_tokens as u64,
5280 total_tokens: (prompt_tokens + completion_tokens) as u64,
5281 context_window: ctx_window,
5282 ..Default::default()
5285 };
5286 (text, vec![], Some(usage), ttft, None)
5287 },
5288 )
5289 }
5290 Err(e) => Err(e),
5291 }
5292 }
5293 };
5294
5295 match result {
5296 Ok((text, mut tool_calls, usage, time_to_first_token_ms, stop_reason)) => {
5297 let text = if !is_remote
5304 && !is_delegated
5305 && req.tools.is_some()
5306 && tool_calls.is_empty()
5307 {
5308 let (clean, parsed) = tasks::generate::parse_tool_calls(&text);
5309 tool_calls = parsed;
5310 clean
5311 } else {
5312 text
5313 };
5314 let latency_ms = start.elapsed().as_millis() as u64;
5315 let estimated_tokens = usage
5316 .as_ref()
5317 .map(|u| u.completion_tokens as usize)
5318 .unwrap_or_else(|| text.split_whitespace().count());
5319 let input_tokens = usage
5324 .as_ref()
5325 .map(|u| u.prompt_tokens as usize)
5326 .unwrap_or(estimated_input);
5327 let (cache_read, cache_creation) = usage
5332 .as_ref()
5333 .map(|u| {
5334 (
5335 u.cache_read_input_tokens as usize,
5336 u.cache_creation_input_tokens as usize,
5337 )
5338 })
5339 .unwrap_or((0, 0));
5340 {
5341 let mut tracker = self.outcome_tracker.write().await;
5342 tracker.record_complete_cached(
5343 &attempt_trace,
5344 latency_ms,
5345 input_tokens,
5346 estimated_tokens,
5347 cache_read,
5348 cache_creation,
5349 );
5350 }
5351 if let Ok(mut cb) = self.adaptive_router.circuit_breakers.lock() {
5357 cb.record_success(&resolved_id);
5358 }
5359 self.auto_save_outcomes().await;
5361
5362 let span = tracing::Span::current();
5364 span.record("model", candidate_name.as_str());
5365 span.record("latency_ms", latency_ms);
5366 if let Some(ttft) = time_to_first_token_ms {
5367 span.record("ttft_ms", ttft);
5368 }
5369 if let Some(ref u) = usage {
5370 span.record("prompt_tokens", u.prompt_tokens);
5371 span.record("completion_tokens", u.completion_tokens);
5372 }
5373
5374 let bounding_boxes = tasks::grounding::parse_boxes(&text);
5377 let local_last_resort = report_local_last_resort_served(
5378 local_last_resort_id.as_deref(),
5379 candidate_id,
5380 &resolved_id,
5381 );
5382 return Ok(InferenceResult {
5383 text,
5384 tool_calls,
5385 bounding_boxes,
5386 trace_id: attempt_trace,
5387 model_used: reported_model_used,
5388 model_identity: bound_model_identity(
5389 catalog_snapshot,
5390 requested_model_id.as_deref(),
5391 &resolved_id,
5392 )?,
5393 latency_ms,
5394 time_to_first_token_ms,
5395 usage,
5396 provider_output_items: captured_provider_output_items,
5397 thinking: captured_thinking,
5398 stop_reason,
5399 auth_fallback_from: auth_dead_lane.clone(),
5400 local_last_resort,
5401 fallback_from: fallback_hops.clone(),
5402 });
5403 }
5404 Err(e) => {
5405 if matches!(e, InferenceError::ControlledTermination) {
5406 self.outcome_tracker
5407 .write()
5408 .await
5409 .record_capability_rejection(&attempt_trace, &e.to_string());
5410 return Err(e);
5411 }
5412 tracing::warn!(
5413 model = %candidate_name,
5414 error = %e,
5415 remaining = candidate_queue.len(),
5416 "model failed, trying next fallback immediately"
5417 );
5418 record_auth_dead_lane(&mut auth_dead_lane, &candidate_name, &e.to_string());
5425 record_route_credential_failure(
5426 &mut route_credential_failure,
5427 &candidate_name,
5428 &e,
5429 was_primary && has_requested_route,
5430 );
5431 record_fallback_from(&mut fallback_hops, &candidate_name, &e);
5432 {
5437 let mut tracker = self.outcome_tracker.write().await;
5438 record_dispatch_failure(&mut tracker, &attempt_trace, &e);
5439 }
5440 if error_counts_against_circuit_breaker(&e) {
5450 let err_str = e.to_string();
5451 let is_client_error =
5452 err_str.contains("API returned 4") && !err_str.contains("429");
5453 if let Ok(mut cb) = self.adaptive_router.circuit_breakers.lock() {
5454 cb.record_failure(&resolved_id);
5456 if is_client_error {
5459 cb.record_failure(&resolved_id);
5460 }
5461 }
5462 }
5463 #[cfg(not(all(
5465 target_os = "macos",
5466 target_arch = "aarch64",
5467 not(car_skip_mlx)
5468 )))]
5469 {
5470 let mut write = self.backend.write().await;
5471 if write.remove(&resolved_id).is_some() {
5472 self.local_admission.mark_evicted(&resolved_id);
5473 }
5474 }
5475 if let InferenceError::GatewayUnconfigured { namespace, .. } = &e {
5503 let before = candidate_queue.len();
5504 candidate_queue.retain(|id| !id.starts_with(namespace.as_str()));
5505 let dropped = before - candidate_queue.len();
5506 if dropped > 0 {
5507 tracing::info!(
5508 %namespace,
5509 dropped,
5510 remaining = candidate_queue.len(),
5511 "gateway has no upstream for this namespace; dropping its \
5512 remaining candidates instead of replaying the same rejection"
5513 );
5514 }
5515 }
5516 if error_ends_fallback_chain(&e) {
5517 let dropped = candidate_queue.len();
5518 candidate_queue.clear();
5519 if dropped > 0 {
5520 tracing::info!(
5521 dropped,
5522 "content refused for this request; ending the fallback chain \
5523 rather than answering with a different model"
5524 );
5525 }
5526 }
5527 if let InferenceError::ProviderAccount { provider, .. } = &e {
5528 let mut rest: Vec<String> = candidate_queue.iter().cloned().collect();
5532 let demoted = routing_ext::demote_provider(provider, &mut rest, |id| {
5533 routing_registry
5534 .get(id)
5535 .or_else(|| routing_registry.find_by_name(id))
5536 .map(|s| s.provider.clone())
5537 });
5538 candidate_queue = rest.into();
5539 if demoted > 0 {
5540 tracing::info!(
5541 %provider,
5542 demoted,
5543 remaining = candidate_queue.len(),
5544 "account-level rejection; deferring this provider's \
5545 remaining candidates to the end of the chain"
5546 );
5547 }
5548 }
5549 if was_primary && !candidate_queue.is_empty() {
5550 let err_str = e.to_string();
5551 let status = parse_api_returned_status(&err_str);
5558 if routing_ext::is_provider_transient(status, &err_str) {
5559 let provider_of = |id: &str| {
5564 routing_registry
5565 .get(id)
5566 .or_else(|| routing_registry.find_by_name(id))
5567 .map(|s| s.provider.clone())
5568 };
5569 let primary = provider_of(candidate_id).unwrap_or_default();
5570 let queue_vec: Vec<String> = candidate_queue.iter().cloned().collect();
5571 if let Some(cross) =
5572 routing_ext::first_cross_provider(&primary, &queue_vec, provider_of)
5573 {
5574 let cross = cross.to_string();
5575 if let Some(pos) = candidate_queue.iter().position(|m| *m == cross)
5576 {
5577 if pos > 0 {
5578 if let Some(m) = candidate_queue.remove(pos) {
5579 tracing::info!(
5580 promoted = %m,
5581 "transient provider error on primary; promoting cross-provider fallback"
5582 );
5583 candidate_queue.push_front(m);
5584 }
5585 }
5586 }
5587 }
5588 }
5589 }
5590 last_error = Some(e);
5591 }
5592 }
5593 }
5594
5595 let underlying = last_error.unwrap_or(InferenceError::InferenceFailed(
5597 "no models available".into(),
5598 ));
5599
5600 let e = apply_route_failure_context(underlying, route_credential_failure.as_ref());
5614 self.auto_save_outcomes().await;
5618 Err(e)
5619 }
5620
5621 async fn generate_stream_raw(
5626 &self,
5627 req: GenerateRequest,
5628 ) -> Result<(String, tokio::sync::mpsc::Receiver<stream::StreamEvent>), InferenceError> {
5629 let routing_registry = self
5630 .request_routing_registry_snapshot(req.model.as_deref())
5631 .await;
5632 if let Some(requested) = req.model.as_deref() {
5633 if routing_registry
5634 .get(requested)
5635 .or_else(|| routing_registry.find_by_name(requested))
5636 .is_none()
5637 {
5638 return Err(InferenceError::ModelNotFound(requested.to_string()));
5639 }
5640 }
5641 let has_tools = Self::request_has_tools(&req);
5642 let has_vision = Self::request_needs_vision(&req);
5643 let (estimated_input, _, _) = self.estimated_tokens(&req, None);
5644 let estimated_footprint = estimated_input.saturating_add(req.params.max_tokens);
5645 let (estimated_cache_read, estimated_cache_write) =
5646 Self::routing_cache_estimates(&req, estimated_input);
5647 let preferred_model = self
5648 .preferred_model_for_capability(ModelCapability::Generate)
5649 .map(str::to_string);
5650 let exclude_set = self
5651 .adaptive_router
5652 .build_exclude_set(req.intent.as_ref(), &routing_registry);
5653 let unpinned_override = self
5654 .lane_pin_for(&req, &routing_registry)
5655 .or(preferred_model)
5656 .filter(|model| !Self::model_is_excluded(&exclude_set, &routing_registry, model));
5657 let decision = match req.model.clone().or(unpinned_override) {
5658 Some(m) => {
5659 let ctx_len = routing_registry
5660 .get(&m)
5661 .or_else(|| routing_registry.find_by_name(&m))
5662 .map(|s| s.context_length)
5663 .unwrap_or(0);
5664 AdaptiveRoutingDecision {
5665 model_id: m.clone(),
5666 model_name: m,
5667 task: InferenceTask::Generate,
5668 complexity: TaskComplexity::assess(&req.prompt),
5669 reason: "explicit model".into(),
5670 strategy: RoutingStrategy::Explicit,
5671 predicted_quality: 0.5,
5672 fallbacks: vec![],
5673 context_length: ctx_len,
5674 needs_compaction: false,
5675 candidates: vec![],
5676 }
5677 }
5678 None => {
5679 let tracker_read = self.outcome_tracker.read().await;
5680 self.adaptive_router
5681 .route_with(crate::adaptive_router::RouteRequest {
5682 estimated_total_tokens: estimated_footprint,
5683 estimated_input_tokens: estimated_input,
5684 estimated_output_tokens: req.params.max_tokens,
5685 estimated_cache_read_tokens: estimated_cache_read,
5686 estimated_cache_write_tokens: estimated_cache_write,
5687 has_tools,
5688 has_vision,
5689 workload: req.params.workload,
5690 intent: req.intent.as_ref(),
5691 ..crate::adaptive_router::RouteRequest::new(
5692 &req.prompt,
5693 &routing_registry,
5694 &tracker_read,
5695 )
5696 })
5697 }
5698 };
5699
5700 if decision.model_id.is_empty() {
5701 let excluded_models = req
5702 .intent
5703 .as_ref()
5704 .map(|hint| hint.exclude_models.join(", "))
5705 .unwrap_or_default();
5706 return Err(InferenceError::NoEligibleModel { excluded_models });
5707 }
5708
5709 #[allow(unused_mut)]
5712 let mut schema = routing_registry
5713 .get(&decision.model_id)
5714 .or_else(|| routing_registry.find_by_name(&decision.model_id))
5715 .cloned();
5716
5717 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
5719 if let Some(ref s) = schema {
5720 if let Some(mlx_equiv) = routing_registry.resolve_mlx_equivalent(s) {
5721 tracing::info!(
5722 from = %s.id, to = %mlx_equiv.id,
5723 "redirecting GGUF model to MLX equivalent on Apple Silicon (stream)"
5724 );
5725 schema = Some(mlx_equiv.clone());
5726 }
5727 }
5728
5729 if has_tools
5735 && schema
5736 .as_ref()
5737 .map(|s| !s.has_capability(ModelCapability::ToolUse))
5738 .unwrap_or(false)
5739 {
5740 let backend = schema
5741 .as_ref()
5742 .map(|s| if s.is_local() { "local" } else { "remote" })
5743 .unwrap_or("unknown");
5744 return Err(InferenceError::UnsupportedMode {
5745 mode: "tool_use",
5746 backend,
5747 reason: "resolved model does not support structured tool calls; configure a tool-capable model (a remote API model, or run the vllm-mlx OpenAI-compatible server)",
5748 });
5749 }
5750
5751 let resolved_model_id = schema
5753 .as_ref()
5754 .map(|s| s.id.clone())
5755 .unwrap_or_else(|| decision.model_id.clone());
5756
5757 let mut req = req;
5764 if let Some(schema) = schema.as_ref() {
5765 req.params.max_tokens = resolved_max_tokens(req.params.max_tokens, schema);
5766 }
5767
5768 let is_remote = schema
5769 .as_ref()
5770 .map(|s| s.is_remote() || s.is_vllm_mlx())
5771 .unwrap_or(false);
5772
5773 let is_codex_cli = schema.as_ref().map(|s| s.is_codex_cli()).unwrap_or(false);
5774 let is_delegated = schema.as_ref().map(|s| s.is_delegated()).unwrap_or(false);
5775
5776 if is_codex_cli {
5777 return Err(InferenceError::UnsupportedMode {
5778 mode: "streaming",
5779 backend: "codex-cli",
5780 reason: "codex exec reports final answer items rather than token deltas; use non-streaming infer/generate_tracked",
5781 });
5782 }
5783
5784 if is_delegated {
5785 let runner = runner::current_inference_runner().ok_or_else(|| {
5789 InferenceError::InferenceFailed(
5790 "model declares ModelSource::Delegated but no inference runner is registered \
5791 (call set_inference_runner / registerInferenceRunner / register_inference_runner)"
5792 .into(),
5793 )
5794 })?;
5795 let (tx, rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
5796 let emitter = runner::EventEmitter::new(tx);
5797 let request = req.clone();
5798 tokio::spawn(async move {
5799 if let Err(e) = runner.run(request, emitter).await {
5800 tracing::warn!(error = %e, "delegated inference runner failed");
5801 }
5802 });
5803 return Ok((resolved_model_id, rx));
5804 }
5805
5806 let mut local_reservation = if !is_remote
5808 || schema.as_ref().is_some_and(ModelSchema::is_vllm_mlx)
5809 {
5810 if let Some(local_schema) = schema
5811 .as_ref()
5812 .filter(|schema| Self::reserve_in_outer_dispatch(schema))
5813 {
5814 Some(
5815 self.reserve_local_request_with_worker_retry(local_schema, estimated_footprint)
5816 .await?,
5817 )
5818 } else {
5819 None
5820 }
5821 } else {
5822 None
5823 };
5824
5825 if !is_remote && schema.as_ref().is_some_and(Self::supports_worker_offload) {
5832 if let Some(offload) = crate::offload::current_local_offload() {
5833 let mut offload_req = req.clone();
5834 offload_req.model = Some(resolved_model_id.clone());
5835 let schema_ref = schema
5836 .as_ref()
5837 .ok_or_else(|| InferenceError::ModelNotFound(resolved_model_id.clone()))?;
5838 let reservation = local_reservation.as_mut().ok_or_else(|| {
5839 InferenceError::InferenceFailed(
5840 "local worker stream missing admission reservation".into(),
5841 )
5842 })?;
5843 if let Some(allocation_id) = offload.resident_allocation_id(&resolved_model_id) {
5844 reservation.bind_allocation_id(&allocation_id);
5845 }
5846 let admission = self.prepare_worker_admission(schema_ref, reservation)?;
5847 let offload_stream = offload.stream_admitted(offload_req, admission).await?;
5848 Self::reconcile_worker_residency(
5849 offload.as_ref(),
5850 &resolved_model_id,
5851 &offload_stream.residency,
5852 offload_stream.retention,
5853 reservation,
5854 )
5855 .await?;
5856 let rx = offload_stream.events;
5857 let rx = match local_reservation {
5858 Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
5859 None => rx,
5860 };
5861 return Ok((resolved_model_id, rx));
5862 }
5863 }
5864
5865 if is_remote {
5866 let mut candidates = vec![schema.unwrap()];
5867 let mut local_fallback_ids = Vec::new();
5868 for fallback_id in &decision.fallbacks {
5869 if let Some(fallback) = routing_registry
5870 .get(fallback_id)
5871 .or_else(|| routing_registry.find_by_name(fallback_id))
5872 {
5873 if (fallback.is_remote() || fallback.is_vllm_mlx())
5874 && (!has_tools || fallback.has_capability(ModelCapability::ToolUse))
5875 && (!has_vision || fallback.has_capability(ModelCapability::Vision))
5876 && !candidates
5877 .iter()
5878 .any(|candidate| candidate.id == fallback.id)
5879 {
5880 candidates.push(fallback.clone());
5881 } else if fallback.is_local()
5882 && !fallback.is_vllm_mlx()
5883 && (!has_tools || fallback.has_capability(ModelCapability::ToolUse))
5884 && (!has_vision || fallback.has_capability(ModelCapability::Vision))
5885 {
5886 local_fallback_ids.push(fallback.id.clone());
5887 }
5888 }
5889 }
5890 let mut last_error = None;
5891 for candidate in candidates {
5892 let (candidate, mut candidate_reservation) = match self
5895 .vllm_live_schema(candidate, local_reservation.take(), estimated_footprint)
5896 .await
5897 {
5898 Ok(candidate) => candidate,
5899 Err(error) => {
5900 last_error = Some(error);
5901 continue;
5902 }
5903 };
5904 self.remote_backend.register_model_keys(&candidate).await;
5905
5906 let spend_guard = self
5907 .spend_limits
5908 .read()
5909 .unwrap()
5910 .as_ref()
5911 .and_then(|limits| limits.per_request_usd)
5912 .map(|budget| {
5913 let mut prompt_tokens =
5914 routing_ext::MidStreamSpendGuard::estimate_tokens(&req.prompt)
5915 + req
5916 .context
5917 .as_deref()
5918 .map(routing_ext::MidStreamSpendGuard::estimate_tokens)
5919 .unwrap_or(0);
5920 prompt_tokens += media_tokens::request_media_and_history_tokens(
5921 req.images.as_deref(),
5922 req.messages.as_deref(),
5923 ) as u64;
5924 if let Some(tools) = &req.tools {
5925 prompt_tokens += routing_ext::MidStreamSpendGuard::estimate_tokens(
5926 &serde_json::to_string(tools).unwrap_or_default(),
5927 );
5928 }
5929 let prices = candidate.cost.prices_for(prompt_tokens as usize);
5930 let input_price = prices
5931 .input_per_mtok
5932 .map(|c| c / 1_000_000.0)
5933 .unwrap_or(0.0);
5934 let output_price = prices
5935 .output_per_mtok
5936 .map(|c| c / 1_000_000.0)
5937 .unwrap_or(0.0);
5938 routing_ext::MidStreamSpendGuard::new(
5939 Some(budget),
5940 prompt_tokens as f64 * input_price,
5941 input_price,
5942 output_price,
5943 )
5944 });
5945
5946 match self
5947 .remote_backend
5948 .generate_stream(
5949 &candidate,
5950 &req.prompt,
5951 req.messages.as_deref(),
5952 req.context.as_deref(),
5953 req.params.temperature,
5954 req.params.max_tokens,
5955 req.tools.as_deref(),
5956 req.images.as_deref(),
5957 req.params.tool_choice.as_deref(),
5958 req.params.parallel_tool_calls,
5959 req.response_format.as_ref(),
5960 spend_guard,
5961 )
5962 .await
5963 {
5964 Ok(receiver) => {
5965 let receiver = Self::hold_optional_reservation_for_stream(
5966 receiver,
5967 candidate_reservation.take(),
5968 );
5969 return Ok((candidate.id, receiver));
5970 }
5971 Err(error) => {
5972 tracing::warn!(
5973 model = %candidate.id,
5974 %error,
5975 "remote stream setup failed; trying routed fallback"
5976 );
5977 last_error = Some(error);
5978 }
5979 }
5980 }
5981
5982 if !req.params.strict_model {
5987 if let Some(local) = self.first_installed_local_model(has_tools) {
5988 let schema = routing_registry
5989 .get(&local)
5990 .or_else(|| routing_registry.find_by_name(&local));
5991 let supports_request = schema.is_some_and(|schema| {
5992 !has_vision || schema.has_capability(ModelCapability::Vision)
5993 });
5994 if supports_request
5995 && !local_fallback_ids.iter().any(|candidate| {
5996 routing_registry
5997 .get(candidate)
5998 .or_else(|| routing_registry.find_by_name(candidate))
5999 .is_some_and(|schema| schema.id == local || schema.name == local)
6000 })
6001 {
6002 local_fallback_ids.push(local);
6003 }
6004 }
6005 for local_id in local_fallback_ids {
6006 let mut fallback_req = req.clone();
6007 fallback_req.model = Some(local_id);
6008 fallback_req.params.strict_model = true;
6009 match Box::pin(self.generate_stream_raw(fallback_req)).await {
6010 Ok(stream) => return Ok(stream),
6011 Err(error) => {
6012 tracing::warn!(%error, "local streaming fallback failed");
6013 last_error = Some(error);
6014 }
6015 }
6016 }
6017 }
6018
6019 Err(last_error.unwrap_or_else(|| {
6020 InferenceError::InferenceFailed(
6021 "no compatible remote streaming model available".to_string(),
6022 )
6023 }))
6024 } else {
6025 let schema =
6026 schema.ok_or_else(|| InferenceError::ModelNotFound(decision.model_id.clone()))?;
6027 let (tx, rx) = tokio::sync::mpsc::channel(64);
6028
6029 #[cfg(any(
6035 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6036 all(target_os = "ios", target_arch = "aarch64")
6037 ))]
6038 {
6039 if schema.is_foundation_models() {
6040 if Self::request_has_video(&req)
6044 || Self::request_has_audio(&req)
6045 || req.images.as_ref().is_some_and(|imgs| !imgs.is_empty())
6046 {
6047 return Err(InferenceError::UnsupportedMode {
6048 mode: "multimodal-content",
6049 backend: "foundation-models",
6050 reason: "the FoundationModels bridge currently exposes text-only \
6051 generation — route image/audio/video to a remote VL model",
6052 });
6053 }
6054 if let Some(tools_defs) = req.tools.clone().filter(|t| !t.is_empty()) {
6060 let prompt = req.prompt.clone();
6061 let instructions = req.context.clone();
6062 let fm_tool_choice = req.params.tool_choice.clone();
6063 let max_tokens = req.params.max_tokens as u32;
6064 let temperature = req.params.temperature;
6065 tokio::task::spawn_blocking(move || {
6066 match crate::backend::foundation_models::generate_with_tools(
6067 &prompt,
6068 instructions.as_deref(),
6069 &tools_defs,
6070 fm_tool_choice.as_deref(),
6071 max_tokens,
6072 temperature as f32,
6073 ) {
6074 Ok((text, tool_calls)) => {
6075 if !text.is_empty() {
6076 let _ = tx.blocking_send(stream::StreamEvent::TextDelta(
6077 text.clone(),
6078 ));
6079 }
6080 let _ = tx.blocking_send(stream::StreamEvent::Done {
6081 text,
6082 tool_calls,
6083 });
6084 }
6085 Err(e) => {
6086 tracing::warn!(
6091 error = %e,
6092 "FoundationModels tool-enabled stream failed"
6093 );
6094 }
6095 }
6096 });
6097 return Ok((resolved_model_id, rx));
6098 }
6099 let prompt = req.prompt.clone();
6100 let instructions = req.context.clone();
6101 let max_tokens = req.params.max_tokens as u32;
6102 let temperature = req.params.temperature;
6103 let tx_clone = tx.clone();
6104 tokio::task::spawn_blocking(move || {
6105 let accum = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
6112 let accum_cb = accum.clone();
6113 let cb = crate::backend::foundation_models::StreamCallback::new(
6114 move |delta: &str| {
6115 if let Ok(mut g) = accum_cb.lock() {
6116 g.push_str(delta);
6117 }
6118 tx_clone
6119 .blocking_send(stream::StreamEvent::TextDelta(
6120 delta.to_string(),
6121 ))
6122 .is_ok()
6123 },
6124 );
6125 let result = crate::backend::foundation_models::stream(
6126 &prompt,
6127 instructions.as_deref(),
6128 max_tokens,
6129 temperature as f32,
6130 cb,
6131 );
6132 let final_text = accum.lock().map(|g| g.clone()).unwrap_or_default();
6133 let _ = tx.blocking_send(stream::StreamEvent::Done {
6134 text: final_text,
6135 tool_calls: vec![],
6136 });
6137 result
6138 });
6139 return Ok((resolved_model_id, rx));
6140 }
6141 }
6142
6143 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6145 {
6146 if !schema.is_mlx() {
6149 return Err(InferenceError::InferenceFailed(format!(
6150 "model '{}' has no MLX equivalent; Candle backend disabled on Apple Silicon",
6151 schema.id
6152 )));
6153 }
6154 let (backend, _retention) = self
6155 .ensure_mlx_backend(
6156 &schema,
6157 local_reservation
6158 .as_mut()
6159 .expect("local stream has admission reservation"),
6160 )
6161 .await?;
6162 let model_id = schema.id.clone();
6163 let cache = Arc::clone(&self.mlx_backends);
6164 let device_guard = Self::mlx_device_lock().lock_owned().await;
6171 tokio::task::spawn_blocking(move || {
6177 let _device_guard = device_guard;
6178 let _ = Self::stream_local_mlx(backend, cache, model_id, req, tx);
6179 });
6180 let rx = match local_reservation {
6181 Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
6182 None => rx,
6183 };
6184 Ok((resolved_model_id, rx))
6185 }
6186
6187 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6188 {
6189 self.ensure_backend(
6190 &schema,
6191 local_reservation
6192 .as_mut()
6193 .expect("local stream has admission reservation"),
6194 )
6195 .await?;
6196 let backend = self.backend.clone();
6197 let model_id = schema.id.clone();
6198 tokio::spawn(async move {
6199 let _ = Self::stream_local_candle(backend, model_id, req, tx).await;
6200 });
6201 let rx = match local_reservation {
6202 Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
6203 None => rx,
6204 };
6205 Ok((resolved_model_id, rx))
6206 }
6207 }
6208 }
6209
6210 pub async fn generate_tracked_stream(
6255 &self,
6256 req: GenerateRequest,
6257 ) -> Result<TrackedStream, InferenceError> {
6258 let (estimated_input, _, _) = self.estimated_tokens(&req, None);
6262 let start = Instant::now();
6263
6264 let (model_id, mut producer_rx) = self.generate_stream_raw(req).await?;
6268
6269 let trace = {
6270 let mut t = self.outcome_tracker.write().await;
6271 t.record_start(&model_id, InferenceTask::Generate, "stream")
6272 };
6273 let trace_for_return = trace.clone();
6277
6278 let (out_tx, out_rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
6279 let tracker = Arc::clone(&self.outcome_tracker);
6280 tokio::spawn(async move {
6281 let mut acc = stream::StreamAccumulator::default();
6282 let mut stream_error: Option<String> = None;
6283 let mut saw_done = false;
6284 let mut receiver_abandoned = false;
6285 while let Some(evt) = producer_rx.recv().await {
6286 if let stream::StreamEvent::Error(message) = &evt {
6287 stream_error = Some(message.clone());
6288 }
6289 if matches!(evt, stream::StreamEvent::Done { .. }) {
6290 saw_done = true;
6291 }
6292 acc.push(&evt);
6293 if out_tx.send(evt).await.is_err() {
6294 receiver_abandoned = true;
6295 break;
6296 }
6297 }
6298 if stream_error.is_none() && !saw_done && !receiver_abandoned {
6299 let error = "stream ended without positive provider completion".to_string();
6300 stream_error = Some(error.clone());
6301 let _ = out_tx.send(stream::StreamEvent::Error(error)).await;
6302 } else if stream_error.is_none() && receiver_abandoned {
6303 stream_error = Some("stream receiver was abandoned before completion".to_string());
6304 }
6305 let (text, tool_calls, usage, _stop) = acc.finish_with_usage();
6306 let latency_ms = start.elapsed().as_millis() as u64;
6307 let input_tokens = usage
6308 .as_ref()
6309 .map(|u| u.prompt_tokens as usize)
6310 .unwrap_or(estimated_input);
6311 let mut output_tokens = usage
6316 .as_ref()
6317 .map(|u| u.completion_tokens as usize)
6318 .unwrap_or_else(|| text.split_whitespace().count());
6319 if output_tokens == 0 && !tool_calls.is_empty() {
6320 output_tokens = 1;
6321 }
6322 let (cache_read, cache_creation) = usage
6327 .as_ref()
6328 .map(|u| {
6329 (
6330 u.cache_read_input_tokens as usize,
6331 u.cache_creation_input_tokens as usize,
6332 )
6333 })
6334 .unwrap_or((0, 0));
6335 let mut t = tracker.write().await;
6336 if let Some(error) = stream_error {
6337 t.record_failure(&trace, &error);
6342 } else {
6343 t.record_complete_cached(
6344 &trace,
6345 latency_ms,
6346 input_tokens,
6347 output_tokens,
6348 cache_read,
6349 cache_creation,
6350 );
6351 }
6352 });
6353
6354 Ok(TrackedStream {
6355 model_used: model_id,
6356 trace_id: trace_for_return,
6357 events: out_rx,
6358 })
6359 }
6360
6361 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6362 async fn stream_local_candle(
6363 backend_lock: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
6364 model_id: String,
6365 req: GenerateRequest,
6366 tx: tokio::sync::mpsc::Sender<stream::StreamEvent>,
6367 ) -> Result<(), InferenceError> {
6368 let mut write = backend_lock.write().await;
6369 let backend = write.get_mut(&model_id).ok_or_else(|| {
6370 InferenceError::InferenceFailed(format!("backend not initialized for {model_id}"))
6371 })?;
6372 backend.clear_kv_cache();
6373
6374 let formatted = tasks::generate::render_chat_prompt(&req);
6375 let tokens = backend.encode(&formatted)?;
6376 let eos = backend.eos_token_id();
6377 let eos_alt = backend.token_id("<|im_end|>");
6378 let params = &req.params;
6379
6380 if tokens.is_empty() {
6381 let _ = tx
6382 .send(stream::StreamEvent::Done {
6383 text: String::new(),
6384 tool_calls: vec![],
6385 })
6386 .await;
6387 return Ok(());
6388 }
6389
6390 let max_ctx = backend.context_length().unwrap_or(32768);
6391 let headroom = params.max_tokens.min(max_ctx / 4);
6392 let max_prompt = max_ctx.saturating_sub(headroom);
6393 let tokens = if tokens.len() > max_prompt {
6394 tokens[tokens.len() - max_prompt..].to_vec()
6395 } else {
6396 tokens
6397 };
6398
6399 let mut generated = Vec::new();
6400 let logits = backend.forward(&tokens, 0)?;
6401 let mut next_token = tasks::generate::sample_token(&logits, params)?;
6402
6403 for _ in 0..params.max_tokens {
6404 if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
6405 break;
6406 }
6407
6408 generated.push(next_token);
6409 let delta = backend.decode(&[next_token])?;
6410 if !delta.is_empty()
6411 && tx
6412 .send(stream::StreamEvent::TextDelta(delta))
6413 .await
6414 .is_err()
6415 {
6416 return Ok(());
6417 }
6418
6419 if !params.stop.is_empty() {
6420 let text_so_far = backend.decode(&generated)?;
6421 if params.stop.iter().any(|s| text_so_far.contains(s)) {
6422 break;
6423 }
6424 }
6425
6426 let pos = tokens.len() + generated.len() - 1;
6427 let logits = backend.forward(&[next_token], pos)?;
6428 next_token = tasks::generate::sample_token(&logits, params)?;
6429 }
6430
6431 let trimmed = tasks::generate::truncate_at_stop(&backend.decode(&generated)?, ¶ms.stop);
6432 let text = tasks::generate::strip_thinking(&trimmed, params.thinking);
6433 let _ = tx
6437 .send(stream::StreamEvent::Usage {
6438 input_tokens: tokens.len() as u64,
6439 output_tokens: generated.len() as u64,
6440 cache_read_input_tokens: 0,
6441 cache_creation_input_tokens: 0,
6442 })
6443 .await;
6444 let _ = tx
6445 .send(stream::StreamEvent::Done {
6446 text,
6447 tool_calls: vec![],
6448 })
6449 .await;
6450 Ok(())
6451 }
6452
6453 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6457 fn stream_local_mlx(
6458 handle: backend_cache::CachedBackend<backend::MlxBackend>,
6459 cache: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
6460 model_id: String,
6461 req: GenerateRequest,
6462 tx: tokio::sync::mpsc::Sender<stream::StreamEvent>,
6463 ) -> Result<(), InferenceError> {
6464 let mut guard = handle.lock().map_err(|_| {
6465 InferenceError::InferenceFailed(format!("MLX backend mutex poisoned for {model_id}"))
6466 })?;
6467 let backend: &mut backend::MlxBackend = &mut guard;
6468 backend.clear_kv_cache();
6469
6470 let formatted = tasks::generate::render_chat_prompt(&req);
6471 let tokens = backend.encode(&formatted)?;
6472 let eos = backend.eos_token_id();
6473 let eos_alt = backend.token_id("<|im_end|>");
6474 let params = &req.params;
6475
6476 if tokens.is_empty() {
6477 let _ = tx.blocking_send(stream::StreamEvent::Done {
6478 text: String::new(),
6479 tool_calls: vec![],
6480 });
6481 return Ok(());
6482 }
6483
6484 let max_ctx = backend.context_length();
6485 let headroom = params.max_tokens.min(max_ctx / 4);
6486 let max_prompt = max_ctx.saturating_sub(headroom);
6487 let tokens = if tokens.len() > max_prompt {
6488 tokens[tokens.len() - max_prompt..].to_vec()
6489 } else {
6490 tokens
6491 };
6492
6493 let mut generated = Vec::new();
6494
6495 let started = std::time::Instant::now();
6501 let timeout = local_decode_timeout();
6502 let heartbeat = std::time::Duration::from_secs(LOCAL_DECODE_HEARTBEAT_SECS);
6503 let mut last_heartbeat = std::time::Duration::ZERO;
6504 tracing::info!(
6505 prompt_tokens = tokens.len(),
6506 max_tokens = params.max_tokens,
6507 timeout_secs = timeout.map(|t| t.as_secs()),
6508 "local stream prefill starting"
6509 );
6510
6511 let logits = match Self::catch_mlx("stream prefill", || backend.forward(&tokens, 0)) {
6516 Ok(v) => v,
6517 Err(e) => {
6518 cache.invalidate(&model_id);
6519 return Err(e);
6520 }
6521 };
6522 let mut next_token = Self::sample_from_logits(&logits, params)?;
6523
6524 for _ in 0..params.max_tokens {
6525 if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
6526 break;
6527 }
6528
6529 generated.push(next_token);
6530 let delta = backend.decode(&[next_token])?;
6531 if !delta.is_empty()
6532 && tx
6533 .blocking_send(stream::StreamEvent::TextDelta(delta))
6534 .is_err()
6535 {
6536 return Ok(());
6537 }
6538
6539 if !params.stop.is_empty() {
6540 let text_so_far = backend.decode(&generated)?;
6541 if params.stop.iter().any(|s| text_so_far.contains(s)) {
6542 break;
6543 }
6544 }
6545
6546 let elapsed = started.elapsed();
6551 if deadline_exceeded(elapsed, timeout) {
6552 tracing::warn!(
6553 elapsed_secs = elapsed.as_secs_f64(),
6554 timeout_secs = timeout.map(|t| t.as_secs()),
6555 completion_tokens = generated.len(),
6556 max_tokens = params.max_tokens,
6557 "local stream hit its wall-clock ceiling and was cut short; \
6558 returning what was streamed so far. Raise or disable it with \
6559 CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables)."
6560 );
6561 let _ = tx.blocking_send(stream::StreamEvent::StopReason(
6566 LOCAL_DECODE_TIMEOUT_STOP_REASON.to_string(),
6567 ));
6568 break;
6569 }
6570 if heartbeat_due(elapsed, last_heartbeat, heartbeat) {
6571 last_heartbeat = elapsed;
6572 tracing::info!(
6573 completion_tokens = generated.len(),
6574 max_tokens = params.max_tokens,
6575 elapsed_secs = elapsed.as_secs_f64(),
6576 tokens_per_sec =
6577 generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON),
6578 "local stream in progress"
6579 );
6580 }
6581
6582 let pos = tokens.len() + generated.len() - 1;
6583 let logits =
6584 match Self::catch_mlx("stream forward", || backend.forward(&[next_token], pos)) {
6585 Ok(v) => v,
6586 Err(e) => {
6587 cache.invalidate(&model_id);
6588 return Err(e);
6589 }
6590 };
6591 next_token = Self::sample_from_logits(&logits, params)?;
6592 }
6593
6594 let trimmed = tasks::generate::truncate_at_stop(&backend.decode(&generated)?, ¶ms.stop);
6595 let text = tasks::generate::strip_thinking(&trimmed, params.thinking);
6596 let _ = tx.blocking_send(stream::StreamEvent::Usage {
6604 input_tokens: tokens.len() as u64,
6605 output_tokens: generated.len() as u64,
6606 cache_read_input_tokens: 0,
6608 cache_creation_input_tokens: 0,
6609 });
6610 let _ = tx.blocking_send(stream::StreamEvent::Done {
6611 text,
6612 tool_calls: vec![],
6613 });
6614 Ok(())
6615 }
6616
6617 pub async fn route_context_snapshot(
6619 &self,
6620 prompt: &str,
6621 workload: RoutingWorkload,
6622 has_tools: bool,
6623 has_vision: bool,
6624 ) -> AdaptiveRoutingDecision {
6625 let routing_registry = self.catalog_registry_snapshot();
6626 let tracker = self.outcome_tracker.read().await;
6627 self.adaptive_router.route_context_aware(
6628 prompt,
6629 0,
6630 &routing_registry,
6631 &tracker,
6632 has_tools,
6633 has_vision,
6634 workload,
6635 )
6636 }
6637
6638 pub async fn generate(&self, req: GenerateRequest) -> Result<String, InferenceError> {
6641 Ok(self.generate_tracked(req).await?.text)
6642 }
6643
6644 pub async fn tokenize(&self, model: &str, text: &str) -> Result<Vec<u32>, InferenceError> {
6656 self.assert_local_for_tokenize(model)?;
6657 let admission_schema = self
6658 .unified_registry
6659 .get(model)
6660 .or_else(|| self.unified_registry.find_by_name(model))
6661 .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?
6662 .clone();
6663 let mut reservation =
6665 self.reserve_local_request(&admission_schema, text.len().div_ceil(4))?;
6666
6667 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6668 {
6669 let schema = admission_schema;
6670 let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6671 let guard = handle.lock().map_err(|_| {
6672 InferenceError::InferenceFailed(format!(
6673 "MLX backend mutex poisoned for {}",
6674 schema.id
6675 ))
6676 })?;
6677 let tokens = guard.tokenize_raw(text)?;
6678 Ok(tokens)
6679 }
6680
6681 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6682 {
6683 self.ensure_backend(&admission_schema, &mut reservation)
6684 .await?;
6685 let read = self.backend.read().await;
6686 let backend = read.get(&admission_schema.id).ok_or_else(|| {
6687 InferenceError::InferenceFailed(
6688 "candle backend missing after ensure_backend".to_string(),
6689 )
6690 })?;
6691 let tokens = backend.tokenize_raw(text)?;
6692 Ok(tokens)
6693 }
6694 }
6695
6696 pub async fn detokenize(&self, model: &str, tokens: &[u32]) -> Result<String, InferenceError> {
6698 self.assert_local_for_tokenize(model)?;
6699 let admission_schema = self
6700 .unified_registry
6701 .get(model)
6702 .or_else(|| self.unified_registry.find_by_name(model))
6703 .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?
6704 .clone();
6705 let mut reservation = self.reserve_local_request(&admission_schema, tokens.len())?;
6707
6708 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6709 {
6710 let schema = admission_schema;
6711 let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6712 let guard = handle.lock().map_err(|_| {
6713 InferenceError::InferenceFailed(format!(
6714 "MLX backend mutex poisoned for {}",
6715 schema.id
6716 ))
6717 })?;
6718 let text = guard.detokenize_raw(tokens)?;
6719 Ok(text)
6720 }
6721
6722 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6723 {
6724 self.ensure_backend(&admission_schema, &mut reservation)
6725 .await?;
6726 let read = self.backend.read().await;
6727 let backend = read.get(&admission_schema.id).ok_or_else(|| {
6728 InferenceError::InferenceFailed(
6729 "candle backend missing after ensure_backend".to_string(),
6730 )
6731 })?;
6732 let text = backend.detokenize_raw(tokens)?;
6733 Ok(text)
6734 }
6735 }
6736
6737 fn assert_local_for_tokenize(&self, model: &str) -> Result<(), InferenceError> {
6741 if let Some(schema) = self
6742 .unified_registry
6743 .get(model)
6744 .or_else(|| self.unified_registry.find_by_name(model))
6745 {
6746 if !schema.is_local() {
6747 return Err(InferenceError::UnsupportedMode {
6748 mode: "tokenize/detokenize",
6749 backend: "remote",
6750 reason: "remote provider tokenizer is not exposed by the runtime; \
6751 use a local model (Qwen3 GGUF / MLX) for tokenizer-correctness checks",
6752 });
6753 }
6754 }
6755 Ok(())
6757 }
6758
6759 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6765 fn catch_mlx<F, T>(context: &str, f: F) -> Result<T, InferenceError>
6766 where
6767 F: FnOnce() -> Result<T, InferenceError>,
6768 {
6769 std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|e| {
6770 InferenceError::InferenceFailed(format!("MLX panicked during {context}: {e:?}"))
6771 })?
6772 }
6773
6774 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6784 fn drive_generation(
6785 backend: &mut dyn backend::local::TextDecoder,
6786 prompt: &str,
6787 params: &GenerateParams,
6788 ) -> Result<backend::local::LocalGeneration, backend::local::DriveError> {
6789 Self::drive_generation_with_timeout(backend, prompt, params, local_decode_timeout())
6790 }
6791
6792 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6796 fn drive_generation_with_timeout(
6797 backend: &mut dyn backend::local::TextDecoder,
6798 prompt: &str,
6799 params: &GenerateParams,
6800 timeout: Option<std::time::Duration>,
6801 ) -> Result<backend::local::LocalGeneration, backend::local::DriveError> {
6802 use backend::local::{DriveError, LocalGeneration};
6803
6804 let start = std::time::Instant::now();
6805
6806 let tokens = backend.encode(prompt).map_err(DriveError::Recoverable)?;
6807 let eos_ids = backend.eos_ids();
6808
6809 if tokens.is_empty() {
6810 backend.clear_kv_cache();
6811 return Ok(LocalGeneration {
6812 text: String::new(),
6813 ttft_ms: None,
6814 stop_reason: None,
6815 prompt_tokens: 0,
6816 completion_tokens: 0,
6817 });
6818 }
6819
6820 let max_ctx = backend.context_length();
6822 let headroom = params.max_tokens.min(max_ctx / 4);
6823 let max_prompt = max_ctx.saturating_sub(headroom);
6824 let tokens = if tokens.len() > max_prompt {
6825 tokens[tokens.len() - max_prompt..].to_vec()
6826 } else {
6827 tokens
6828 };
6829
6830 let mut generated = Vec::new();
6831
6832 tracing::info!(
6839 prompt_tokens = tokens.len(),
6840 max_tokens = params.max_tokens,
6841 timeout_secs = timeout.map(|t| t.as_secs()),
6842 "local prefill starting"
6843 );
6844
6845 let offset = backend.begin_prompt(&tokens);
6849 let logits = Self::catch_mlx("prefill", || backend.forward(&tokens[offset..], offset))
6850 .map_err(DriveError::BackendCorrupted)?;
6851 let mut next_token =
6852 Self::sample_from_logits(&logits, params).map_err(DriveError::Recoverable)?;
6853 let ttft_ms = Some(start.elapsed().as_millis() as u64);
6854
6855 tracing::info!(
6860 prompt_tokens = tokens.len(),
6861 max_tokens = params.max_tokens,
6862 prefill_ms = ttft_ms,
6863 timeout_secs = timeout.map(|t| t.as_secs()),
6864 "local decode starting"
6865 );
6866
6867 let mut natural_stop = false;
6870 let mut timed_out = false;
6871 let heartbeat = std::time::Duration::from_secs(LOCAL_DECODE_HEARTBEAT_SECS);
6872 let mut last_heartbeat = std::time::Duration::ZERO;
6873 for _ in 0..params.max_tokens {
6874 if eos_ids.contains(&next_token) {
6875 natural_stop = true;
6876 break;
6877 }
6878
6879 generated.push(next_token);
6880
6881 if !params.stop.is_empty() {
6882 let text_so_far = backend
6883 .decode(&generated)
6884 .map_err(DriveError::Recoverable)?;
6885 if params.stop.iter().any(|s| text_so_far.contains(s)) {
6886 natural_stop = true;
6887 break;
6888 }
6889 }
6890
6891 let elapsed = start.elapsed();
6892 if deadline_exceeded(elapsed, timeout) {
6893 timed_out = true;
6894 break;
6895 }
6896 if heartbeat_due(elapsed, last_heartbeat, heartbeat) {
6897 last_heartbeat = elapsed;
6898 tracing::info!(
6899 completion_tokens = generated.len(),
6900 max_tokens = params.max_tokens,
6901 elapsed_secs = elapsed.as_secs_f64(),
6902 tokens_per_sec =
6903 generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON),
6904 "local decode in progress"
6905 );
6906 }
6907
6908 let pos = tokens.len() + generated.len() - 1;
6909 let logits = Self::catch_mlx("forward", || backend.forward(&[next_token], pos))
6910 .map_err(DriveError::BackendCorrupted)?;
6911 next_token =
6912 Self::sample_from_logits(&logits, params).map_err(DriveError::Recoverable)?;
6913 }
6914
6915 let elapsed = start.elapsed();
6916 let tokens_per_sec = generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON);
6917 if timed_out {
6918 tracing::warn!(
6919 elapsed_secs = elapsed.as_secs_f64(),
6920 timeout_secs = timeout.map(|t| t.as_secs()),
6921 completion_tokens = generated.len(),
6922 max_tokens = params.max_tokens,
6923 tokens_per_sec,
6924 "local decode hit its wall-clock ceiling and was cut short; \
6925 returning the partial response. Raise or disable it with \
6926 CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables)."
6927 );
6928 } else {
6929 tracing::info!(
6930 elapsed_secs = elapsed.as_secs_f64(),
6931 completion_tokens = generated.len(),
6932 max_tokens = params.max_tokens,
6933 tokens_per_sec,
6934 natural_stop,
6935 "local decode finished"
6936 );
6937 }
6938
6939 let decoded = backend
6940 .decode(&generated)
6941 .map_err(DriveError::Recoverable)?;
6942 let text = tasks::generate::truncate_at_stop(&decoded, ¶ms.stop);
6943 let stop_reason = Some(
6944 if timed_out {
6945 LOCAL_DECODE_TIMEOUT_STOP_REASON
6946 } else if natural_stop {
6947 "stop"
6948 } else {
6949 "length"
6950 }
6951 .to_string(),
6952 );
6953 Ok(LocalGeneration {
6954 text: tasks::generate::strip_thinking(&text, params.thinking),
6955 ttft_ms,
6956 stop_reason,
6957 prompt_tokens: tokens.len(),
6958 completion_tokens: generated.len(),
6959 })
6960 }
6961
6962 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6965 async fn generate_mlx(
6975 &self,
6976 req: GenerateRequest,
6977 model_id: &str,
6978 reservation: &mut resource_policy::LocalLoadReservation,
6979 ) -> Result<(String, Option<TokenUsage>, Option<u64>, Option<String>), InferenceError> {
6980 let schema = self
6981 .unified_registry
6982 .get(model_id)
6983 .cloned()
6984 .ok_or_else(|| {
6985 InferenceError::InferenceFailed(format!(
6986 "generate_mlx: unknown schema id {model_id}"
6987 ))
6988 })?;
6989 let (handle, _retention) = self.ensure_mlx_backend(&schema, reservation).await?;
6990 let formatted = tasks::generate::render_chat_prompt(&req);
6997 let params = req.params.clone();
6998 let model_id = model_id.to_string();
6999 let cache = self.mlx_backends.clone();
7000 let device_guard = Self::mlx_device_lock().lock_owned().await;
7001 let detached_lease = reservation.detached_lease();
7002
7003 run_admitted_blocking(detached_lease, move || {
7009 let _device_guard = device_guard;
7010 let mut guard = handle.lock().map_err(|_| {
7011 InferenceError::InferenceFailed(format!(
7012 "MLX backend mutex poisoned for {model_id}"
7013 ))
7014 })?;
7015 let backend: &mut backend::MlxBackend = &mut guard;
7016 let ctx_window = backend.context_length() as u64;
7017 match Self::drive_generation(backend, &formatted, ¶ms) {
7018 Ok(gen) => {
7019 let usage = TokenUsage {
7020 prompt_tokens: gen.prompt_tokens as u64,
7021 completion_tokens: gen.completion_tokens as u64,
7022 total_tokens: (gen.prompt_tokens + gen.completion_tokens) as u64,
7023 context_window: ctx_window,
7024 ..Default::default()
7025 };
7026 Ok((gen.text, Some(usage), gen.ttft_ms, gen.stop_reason))
7027 }
7028 Err(backend::local::DriveError::Recoverable(error)) => Err(error),
7029 Err(backend::local::DriveError::BackendCorrupted(error)) => {
7030 drop(guard);
7031 cache.invalidate(&model_id);
7032 Err(error)
7033 }
7034 }
7035 })
7036 .await
7037 .map_err(|error| {
7038 InferenceError::InferenceFailed(format!("native MLX task panicked: {error}"))
7039 })?
7040 }
7041
7042 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7047 async fn generate_local(
7048 &self,
7049 req: GenerateRequest,
7050 model_id: &str,
7051 reservation: &mut resource_policy::LocalLoadReservation,
7052 ) -> Result<
7053 (
7054 String,
7055 Vec<tasks::generate::ToolCall>,
7056 Option<TokenUsage>,
7057 Option<u64>,
7058 Option<String>,
7059 ),
7060 InferenceError,
7061 > {
7062 let schema = self
7063 .unified_registry
7064 .get(model_id)
7065 .cloned()
7066 .ok_or_else(|| {
7067 InferenceError::InferenceFailed(format!(
7068 "generate_local: unknown schema id {model_id}"
7069 ))
7070 })?;
7071 let (handle, _retention) = self.ensure_local_backend(&schema, reservation).await?;
7072 let params = req.params.clone();
7073 let model_id = model_id.to_string();
7074 let cache = self.local_backends.clone();
7075 let device_guard = Self::mlx_device_lock().lock_owned().await;
7076 let detached_lease = reservation.detached_lease();
7077
7078 run_admitted_blocking(detached_lease, move || {
7079 let _device_guard = device_guard;
7080 let mut guard = handle.lock().map_err(|_| {
7081 InferenceError::InferenceFailed(format!(
7082 "local backend mutex poisoned for {model_id}"
7083 ))
7084 })?;
7085 let formatted = guard.render_prompt(&req)?;
7086 let backend: &mut dyn backend::local::TextDecoder = &mut **guard;
7087 let outcome = Self::drive_generation(backend, &formatted, ¶ms);
7088 match outcome {
7089 Ok(gen) => {
7090 let (clean, tool_calls) = guard.parse_tool_calls(&gen.text);
7091 let usage = TokenUsage {
7092 prompt_tokens: gen.prompt_tokens as u64,
7093 completion_tokens: gen.completion_tokens as u64,
7094 total_tokens: (gen.prompt_tokens + gen.completion_tokens) as u64,
7095 context_window: guard.context_length() as u64,
7096 ..Default::default()
7097 };
7098 Ok((clean, tool_calls, Some(usage), gen.ttft_ms, gen.stop_reason))
7099 }
7100 Err(backend::local::DriveError::Recoverable(error)) => Err(error),
7101 Err(backend::local::DriveError::BackendCorrupted(error)) => {
7102 drop(guard);
7103 cache.invalidate(&model_id);
7104 Err(error)
7105 }
7106 }
7107 })
7108 .await
7109 .map_err(|error| {
7110 InferenceError::InferenceFailed(format!("native local task panicked: {error}"))
7111 })?
7112 }
7113
7114 #[allow(dead_code)] fn apply_top_k_top_p(probs: &mut [f32], top_k: usize, top_p: f64) {
7122 if top_k > 0 && top_k < probs.len() {
7124 let mut indexed: Vec<(usize, f32)> = probs.iter().copied().enumerate().collect();
7125 indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7126 let allowed: std::collections::HashSet<usize> =
7127 indexed[..top_k].iter().map(|(i, _)| *i).collect();
7128 for (i, p) in probs.iter_mut().enumerate() {
7129 if !allowed.contains(&i) {
7130 *p = 0.0;
7131 }
7132 }
7133 let sum: f32 = probs.iter().sum();
7134 if sum > 0.0 {
7135 for p in probs.iter_mut() {
7136 *p /= sum;
7137 }
7138 }
7139 }
7140
7141 if top_p < 1.0 {
7143 let mut indexed: Vec<(usize, f32)> = probs.iter().copied().enumerate().collect();
7144 indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7145 let mut cumsum = 0.0f32;
7146 let mut cutoff_idx = indexed.len();
7147 for (i, &(_, p)) in indexed.iter().enumerate() {
7148 cumsum += p;
7149 if cumsum > top_p as f32 {
7150 cutoff_idx = i + 1;
7151 break;
7152 }
7153 }
7154 let allowed: std::collections::HashSet<usize> =
7155 indexed[..cutoff_idx].iter().map(|(i, _)| *i).collect();
7156 for (i, p) in probs.iter_mut().enumerate() {
7157 if !allowed.contains(&i) {
7158 *p = 0.0;
7159 }
7160 }
7161 let sum: f32 = probs.iter().sum();
7162 if sum > 0.0 {
7163 for p in probs.iter_mut() {
7164 *p /= sum;
7165 }
7166 }
7167 }
7168 }
7169
7170 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7172 fn sample_from_logits(logits: &[f32], params: &GenerateParams) -> Result<u32, InferenceError> {
7173 if params.temperature <= 0.0 {
7174 let (idx, _) = logits
7176 .iter()
7177 .enumerate()
7178 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
7179 .ok_or_else(|| InferenceError::InferenceFailed("empty logits".into()))?;
7180 return Ok(idx as u32);
7181 }
7182
7183 let temp = params.temperature as f32;
7185 let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
7186 let mut probs: Vec<f32> = logits
7187 .iter()
7188 .map(|&l| ((l - max_logit) / temp).exp())
7189 .collect();
7190 let sum: f32 = probs.iter().sum();
7191 for p in &mut probs {
7192 *p /= sum;
7193 }
7194
7195 Self::apply_top_k_top_p(&mut probs, params.top_k, params.top_p);
7201
7202 use rand::Rng;
7204 let mut rng = rand::rng();
7205 let r: f32 = rng.random();
7206 let mut cumsum = 0.0;
7207 for (i, &p) in probs.iter().enumerate() {
7208 cumsum += p;
7209 if cumsum >= r {
7210 return Ok(i as u32);
7211 }
7212 }
7213 Ok((probs.len() - 1) as u32)
7214 }
7215
7216 pub async fn embed(&self, req: EmbedRequest) -> Result<Vec<Vec<f32>>, InferenceError> {
7219 let instruction = req
7220 .instruction
7221 .as_deref()
7222 .unwrap_or("Retrieve relevant memory facts");
7223 let embedding_model = self
7224 .preferred_model_for_capability(ModelCapability::Embed)
7225 .unwrap_or(&self.config.embedding_model);
7226 let admission_schema = self
7227 .unified_registry
7228 .get(embedding_model)
7229 .or_else(|| self.unified_registry.find_by_name(embedding_model))
7230 .ok_or_else(|| InferenceError::ModelNotFound(embedding_model.to_string()))?
7231 .clone();
7232 let estimated_tokens = req.texts.iter().map(|text| text.len().div_ceil(4)).sum();
7233 let mut reservation = self.reserve_local_request(&admission_schema, estimated_tokens)?;
7235
7236 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7237 {
7238 let model_id = self.ensure_mlx_embedding_backend().await?;
7239 let schema = self
7240 .unified_registry
7241 .get(&model_id)
7242 .cloned()
7243 .ok_or_else(|| {
7244 InferenceError::InferenceFailed(format!("embed: unknown schema id {model_id}"))
7245 })?;
7246 let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
7247 let _device_guard = Self::mlx_device_lock().lock_owned().await;
7251 let mut guard = handle.lock().map_err(|_| {
7252 InferenceError::InferenceFailed(format!(
7253 "MLX embedding backend mutex poisoned for {model_id}"
7254 ))
7255 })?;
7256 let backend: &mut backend::MlxBackend = &mut guard;
7257
7258 let mut results = Vec::with_capacity(req.texts.len());
7259 for text in &req.texts {
7260 let embedding = if req.is_query {
7261 backend.embed_query(text, instruction)?
7262 } else {
7263 backend.embed_one(text)?
7264 };
7265 results.push(embedding);
7266 }
7267 Ok(results)
7268 }
7269
7270 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7271 {
7272 self.ensure_embedding_backend(&mut reservation).await?;
7273 let mut write = self.embedding_backend.write().await;
7274 let backend = write.as_mut().unwrap();
7275
7276 let mut results = Vec::with_capacity(req.texts.len());
7277 for text in &req.texts {
7278 let embedding = if req.is_query {
7279 backend.embed_query(text, instruction)?
7280 } else {
7281 backend.embed_one(text)?
7282 };
7283 results.push(embedding);
7284 }
7285 Ok(results)
7286 }
7287 }
7288
7289 pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResult, InferenceError> {
7319 if req.documents.is_empty() {
7320 return Ok(RerankResult {
7321 ranked: Vec::new(),
7322 model_used: None,
7323 });
7324 }
7325
7326 let model_name = match req.model.clone() {
7327 Some(m) => m,
7328 None => self
7329 .preferred_model_for_capability(ModelCapability::Rerank)
7330 .map(str::to_string)
7331 .ok_or_else(|| {
7332 InferenceError::InferenceFailed(
7333 "no reranker model available — pull a Qwen3-Reranker model first".into(),
7334 )
7335 })?,
7336 };
7337
7338 let schema = self
7339 .unified_registry
7340 .find_by_name(&model_name)
7341 .or_else(|| self.unified_registry.get(&model_name))
7342 .cloned()
7343 .ok_or_else(|| {
7344 InferenceError::InferenceFailed(format!(
7345 "rerank: unknown reranker model {model_name}"
7346 ))
7347 })?;
7348 if !schema.has_capability(ModelCapability::Rerank) {
7349 return Err(InferenceError::InferenceFailed(format!(
7350 "model {} does not declare the Rerank capability",
7351 schema.name
7352 )));
7353 }
7354
7355 let instruction = req.instruction.as_deref().unwrap_or(
7356 "Given a web search query, retrieve relevant passages that answer the query",
7357 );
7358
7359 let mut scored: Vec<RerankedDocument> = Vec::with_capacity(req.documents.len());
7360 for (idx, doc) in req.documents.iter().enumerate() {
7361 let prompt = rerank_prompt(instruction, &req.query, doc);
7362 let gen_req = GenerateRequest {
7363 prompt,
7364 model: Some(schema.id.clone()),
7365 params: tasks::generate::GenerateParams {
7366 temperature: 0.0,
7367 max_tokens: 3,
7371 thinking: tasks::generate::ThinkingMode::Off,
7372 ..Default::default()
7373 },
7374 context: None,
7375 context_stable_prefix: None,
7376 tools: None,
7377 images: None,
7378 messages: None,
7379 cache_control: false,
7380 response_format: None,
7381 intent: None,
7382 client_ref: None,
7383 expected_row_digest: None,
7384 expected_catalog_revision: None,
7385 caller: None,
7386 };
7387 let out = self.generate(gen_req).await?;
7388 let score = score_from_rerank_output(&out, &schema.name);
7389 scored.push(RerankedDocument {
7390 index: idx,
7391 score,
7392 document: doc.clone(),
7393 });
7394 }
7395
7396 scored.sort_by(|a, b| {
7399 b.score
7400 .partial_cmp(&a.score)
7401 .unwrap_or(std::cmp::Ordering::Equal)
7402 .then_with(|| a.index.cmp(&b.index))
7403 });
7404 if let Some(n) = req.top_n {
7405 scored.truncate(n);
7406 }
7407
7408 Ok(RerankResult {
7409 ranked: scored,
7410 model_used: Some(schema.name),
7411 })
7412 }
7413
7414 pub async fn ground(&self, req: GroundRequest) -> Result<GroundResult, InferenceError> {
7424 let model_name = match req.model.clone() {
7425 Some(m) => m,
7426 None => self
7427 .preferred_model_for_capability(ModelCapability::Grounding)
7428 .map(str::to_string)
7429 .ok_or_else(|| {
7430 InferenceError::InferenceFailed(
7431 "no grounding-capable model available — pull a Qwen2.5-VL model first"
7432 .into(),
7433 )
7434 })?,
7435 };
7436
7437 let gen_req = GenerateRequest {
7438 prompt: req.prompt.clone(),
7439 model: Some(model_name),
7440 params: GenerateParams::default(),
7441 context: None,
7442 context_stable_prefix: None,
7443 tools: None,
7444 images: Some(vec![req.image.clone()]),
7445 messages: None,
7446 cache_control: false,
7447 response_format: None,
7448 intent: None,
7449 client_ref: None,
7450 expected_row_digest: None,
7451 expected_catalog_revision: None,
7452 caller: None,
7453 };
7454 let result = self.generate_tracked(gen_req).await?;
7455 Ok(GroundResult {
7456 boxes: result.bounding_boxes,
7457 raw_text: result.text,
7458 model_used: Some(result.model_used),
7459 })
7460 }
7461
7462 pub async fn classify(
7465 &self,
7466 req: ClassifyRequest,
7467 ) -> Result<Vec<ClassifyResult>, InferenceError> {
7468 let model = match req.model.clone().or_else(|| {
7469 self.preferred_model_for_capability(ModelCapability::Classify)
7470 .map(str::to_string)
7471 }) {
7472 Some(m) => m,
7473 None => {
7474 let m = self.router.route_small(&self.registry);
7475 debug!(model = %m, "auto-routed classify request");
7476 m
7477 }
7478 };
7479 let schema = self
7480 .unified_registry
7481 .get(&model)
7482 .or_else(|| self.unified_registry.find_by_name(&model))
7483 .ok_or_else(|| InferenceError::ModelNotFound(model.clone()))?
7484 .clone();
7485
7486 if !schema.is_local() {
7487 return self.classify_via_generate(req, &schema.id).await;
7488 }
7489
7490 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7493 {
7494 return self.classify_via_generate(req, &model).await;
7495 }
7496
7497 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7498 {
7499 let mut reservation =
7501 self.reserve_local_request(&schema, req.text.len().div_ceil(4))?;
7502 self.ensure_backend(&schema, &mut reservation).await?;
7503 let mut write = self.backend.write().await;
7504 let backend = write.get_mut(&schema.id).ok_or_else(|| {
7505 InferenceError::InferenceFailed(format!(
7506 "candle backend missing after ensure_backend for {}",
7507 schema.id
7508 ))
7509 })?;
7510 let result = tasks::classify::classify(backend, req).await?;
7511 Ok(result)
7512 }
7513 }
7514
7515 async fn classify_via_generate(
7518 &self,
7519 req: ClassifyRequest,
7520 model: &str,
7521 ) -> Result<Vec<ClassifyResult>, InferenceError> {
7522 let labels_str = req
7523 .labels
7524 .iter()
7525 .enumerate()
7526 .map(|(i, l)| format!("{}. {}", i + 1, l))
7527 .collect::<Vec<_>>()
7528 .join("\n");
7529
7530 let prompt = format!(
7531 "Classify the following text into one of these categories:\n\
7532 {labels_str}\n\n\
7533 Text: {}\n\n\
7534 Respond with ONLY the category name, nothing else.",
7535 req.text
7536 );
7537
7538 let gen_req = GenerateRequest {
7539 prompt,
7540 model: Some(model.to_string()),
7541 params: tasks::generate::GenerateParams {
7542 temperature: 0.0,
7543 max_tokens: 32,
7544 thinking: tasks::generate::ThinkingMode::Off,
7547 ..Default::default()
7548 },
7549 context: None,
7550 context_stable_prefix: None,
7551 tools: None,
7552 images: None,
7553 messages: None,
7554 cache_control: false,
7555 response_format: None,
7556 intent: None,
7557 client_ref: None,
7558 expected_row_digest: None,
7559 expected_catalog_revision: None,
7560 caller: None,
7561 };
7562
7563 let response = self.generate(gen_req).await?;
7564 let response_lower = response.trim().to_lowercase();
7565
7566 let mut results: Vec<ClassifyResult> = req
7567 .labels
7568 .iter()
7569 .map(|label| {
7570 let label_lower = label.to_lowercase();
7571 let score = if response_lower == label_lower {
7572 1.0
7573 } else if response_lower.contains(&label_lower) {
7574 0.8
7575 } else {
7576 let label_words: Vec<&str> = label_lower.split_whitespace().collect();
7577 let matches = label_words
7578 .iter()
7579 .filter(|w| response_lower.contains(**w))
7580 .count();
7581 if label_words.is_empty() {
7582 0.0
7583 } else {
7584 0.5 * (matches as f64 / label_words.len() as f64)
7585 }
7586 };
7587 ClassifyResult {
7588 label: label.clone(),
7589 score,
7590 }
7591 })
7592 .collect();
7593
7594 results.sort_by(|a, b| {
7595 b.score
7596 .partial_cmp(&a.score)
7597 .unwrap_or(std::cmp::Ordering::Equal)
7598 });
7599
7600 let total: f64 = results.iter().map(|r| r.score).sum();
7601 if total > 0.0 {
7602 for r in &mut results {
7603 r.score /= total;
7604 }
7605 }
7606
7607 Ok(results)
7608 }
7609
7610 pub async fn transcribe(
7612 &self,
7613 req: TranscribeRequest,
7614 ) -> Result<TranscribeResult, InferenceError> {
7615 let candidates =
7616 self.speech_candidates(ModelCapability::SpeechToText, req.model.as_deref())?;
7617 let mut last_error = None;
7618 let mut local_resource_block = None;
7619
7620 for schema in candidates {
7621 let mut reservation = match self.admit_speech_candidate(&schema, req.model.is_some()) {
7623 SpeechCandidateAdmission::Proceed(reservation) => reservation,
7624 SpeechCandidateAdmission::SkipBlocked(error) => {
7625 local_resource_block = Some(error.to_string());
7626 last_error = Some(error);
7627 continue;
7628 }
7629 SpeechCandidateAdmission::FailBlocked(error) => return Err(error),
7630 };
7631 let result = match &schema.source {
7632 ModelSource::Mlx { .. } => {
7633 self.transcribe_local_mlx(&schema, &req, reservation.as_mut())
7634 .await
7635 }
7636 ModelSource::WhisperCpp { model } => {
7637 self.transcribe_whisper(&schema, model, &req, reservation.as_mut())
7638 .await
7639 }
7640 ModelSource::Proprietary { provider, .. } if provider == "elevenlabs" => {
7641 self.transcribe_elevenlabs(&schema, &req).await
7642 }
7643 _ => Err(InferenceError::InferenceFailed(format!(
7644 "speech-to-text not implemented for model source: {}",
7645 schema.id
7646 ))),
7647 };
7648
7649 match result {
7650 Ok(mut result) => {
7651 if matches!(schema.source, ModelSource::Proprietary { .. }) {
7652 if let Some(reason) = local_resource_block.take() {
7653 result.routing_explanation = Some(format!(
7654 "Local speech recognition was blocked ({reason}); CAR routed to a remote provider, which may affect privacy and cost."
7655 ));
7656 }
7657 }
7658 return Ok(result);
7659 }
7660 Err(err) if req.model.is_some() => return Err(err),
7661 Err(err) => last_error = Some(err),
7662 }
7663 }
7664
7665 Err(last_error.unwrap_or_else(|| {
7666 InferenceError::InferenceFailed("no speech-to-text models available".into())
7667 }))
7668 }
7669
7670 pub async fn synthesize(
7672 &self,
7673 req: SynthesizeRequest,
7674 ) -> Result<SynthesizeResult, InferenceError> {
7675 let candidates =
7676 self.speech_candidates(ModelCapability::TextToSpeech, req.model.as_deref())?;
7677 let mut last_error = None;
7678 let mut local_resource_block = None;
7679
7680 for schema in candidates {
7681 let mut reservation = match self.admit_speech_candidate(&schema, req.model.is_some()) {
7683 SpeechCandidateAdmission::Proceed(reservation) => reservation,
7684 SpeechCandidateAdmission::SkipBlocked(error) => {
7685 local_resource_block = Some(error.to_string());
7686 last_error = Some(error);
7687 continue;
7688 }
7689 SpeechCandidateAdmission::FailBlocked(error) => return Err(error),
7690 };
7691 let result = match &schema.source {
7692 ModelSource::Mlx { .. } => {
7693 self.synthesize_local_mlx(&schema, &req, reservation.as_mut())
7694 .await
7695 }
7696 ModelSource::WindowsSpeech {} => {
7697 self.synthesize_windows_speech(&schema, &req).await
7698 }
7699 ModelSource::Proprietary { provider, .. } if provider == "elevenlabs" => {
7700 self.synthesize_elevenlabs(&schema, &req).await
7701 }
7702 _ => Err(InferenceError::InferenceFailed(format!(
7703 "text-to-speech not implemented for model source: {}",
7704 schema.id
7705 ))),
7706 };
7707
7708 match result {
7709 Ok(mut result) => {
7710 if matches!(schema.source, ModelSource::Proprietary { .. }) {
7711 if let Some(reason) = local_resource_block.take() {
7712 result.routing_explanation = Some(format!(
7713 "Local speech synthesis was blocked ({reason}); CAR routed to a remote provider, which may affect privacy and cost."
7714 ));
7715 }
7716 }
7717 return Ok(result);
7718 }
7719 Err(err) if req.model.is_some() => return Err(err),
7720 Err(err) => last_error = Some(err),
7721 }
7722 }
7723
7724 Err(last_error.unwrap_or_else(|| {
7725 InferenceError::InferenceFailed("no text-to-speech models available".into())
7726 }))
7727 }
7728
7729 async fn synthesize_windows_speech(
7735 &self,
7736 schema: &ModelSchema,
7737 req: &SynthesizeRequest,
7738 ) -> Result<SynthesizeResult, InferenceError> {
7739 #[cfg(target_os = "windows")]
7740 {
7741 let text = req.text.clone();
7742 let voice = req.voice.clone().unwrap_or_default();
7743 let rate = req.speed.unwrap_or(1.0) as f64;
7744 let bytes =
7745 tokio::task::spawn_blocking(move || winrt_synthesize_wav(&text, &voice, rate))
7746 .await
7747 .map_err(|e| {
7748 InferenceError::InferenceFailed(format!("winrt tts join: {e}"))
7749 })??;
7750 let dest = requested_or_temp_output(req.output_path.as_deref(), "wav")?;
7751 ensure_parent_dir(&dest)?;
7752 std::fs::write(&dest, &bytes)?;
7753 Ok(SynthesizeResult {
7754 audio_path: dest.to_string_lossy().to_string(),
7755 media_type: "audio/wav".to_string(),
7756 model_used: Some(schema.name.clone()),
7757 voice_used: req.voice.clone(),
7758 routing_explanation: None,
7759 })
7760 }
7761 #[cfg(not(target_os = "windows"))]
7762 {
7763 let _ = (schema, req);
7764 Err(InferenceError::InferenceFailed(
7765 "Windows OS TTS is only available on Windows".into(),
7766 ))
7767 }
7768 }
7769
7770 pub async fn generate_image(
7772 &self,
7773 req: GenerateImageRequest,
7774 ) -> Result<GenerateImageResult, InferenceError> {
7775 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7791 {
7792 use crate::backend::external_flux;
7793 let use_external = !external_flux::native_backend_serves(req.model.as_deref());
7794 if use_external {
7795 tracing::info!(
7796 model = req.model.as_deref().unwrap_or("<default>"),
7797 "no native Rust implementation for this image architecture; routing to mflux"
7798 );
7799 let schema = self
7800 .media_generation_candidates(
7801 ModelCapability::ImageGeneration,
7802 req.model.as_deref(),
7803 )?
7804 .into_iter()
7805 .next()
7806 .ok_or_else(|| {
7807 InferenceError::InferenceFailed(
7808 "no image generation models available".into(),
7809 )
7810 })?;
7811 let mut reservation = self.reserve_local_request(&schema, 0)?;
7813 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7814 reservation
7815 .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
7816 .map_err(InferenceError::from)?;
7817 let mut req = req;
7818 req.model = self.resolve_external_hf_repo(
7819 req.model.as_deref(),
7820 ModelCapability::ImageGeneration,
7821 );
7822 return external_flux::generate_image(&req);
7823 }
7824 tracing::info!("using native Rust MLX Flux backend");
7825 }
7826
7827 let candidates = self
7828 .media_generation_candidates(ModelCapability::ImageGeneration, req.model.as_deref())?;
7829 let mut last_error = None;
7830
7831 for schema in candidates {
7832 #[cfg_attr(
7834 not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
7835 allow(unused_mut, unused_variables)
7836 )]
7837 let mut reservation = self.reserve_local_request(&schema, 0)?;
7838 let result = match &schema.source {
7839 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7840 ModelSource::Mlx { .. } => {
7841 self.generate_image_native_mlx(&schema, &req, &mut reservation)
7842 .await
7843 }
7844 _ => Err(InferenceError::InferenceFailed(format!(
7845 "image generation not implemented for model source: {}",
7846 schema.id
7847 ))),
7848 };
7849
7850 match result {
7851 Ok(result) => return Ok(result),
7852 Err(err) if req.model.is_some() => return Err(err),
7853 Err(err) => last_error = Some(err),
7854 }
7855 }
7856
7857 Err(last_error.unwrap_or_else(|| {
7858 InferenceError::InferenceFailed("no image generation models available".into())
7859 }))
7860 }
7861
7862 pub async fn generate_image_batch(
7878 &self,
7879 req: GenerateImageRequest,
7880 ) -> Result<Vec<GenerateImageResult>, InferenceError> {
7881 let count = req.variant_count.unwrap_or(1).max(1);
7882 if count == 1 {
7883 return self.generate_image(req).await.map(|r| vec![r]);
7884 }
7885 let base_seed = req.seed.unwrap_or(0);
7886 let mut results = Vec::with_capacity(count as usize);
7887 for i in 0..count {
7888 let mut variant_req = req.clone();
7893 variant_req.seed = Some(base_seed.wrapping_add(i as u64));
7894 variant_req.variant_count = Some(1);
7898 results.push(self.generate_image(variant_req).await?);
7899 }
7900 Ok(results)
7901 }
7902
7903 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7915 fn mlx_device_lock() -> Arc<tokio::sync::Mutex<()>> {
7916 static MLX_DEVICE_LOCK: std::sync::OnceLock<Arc<tokio::sync::Mutex<()>>> =
7917 std::sync::OnceLock::new();
7918 MLX_DEVICE_LOCK
7919 .get_or_init(|| Arc::new(tokio::sync::Mutex::new(())))
7920 .clone()
7921 }
7922
7923 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7925 async fn generate_image_native_mlx(
7926 &self,
7927 schema: &ModelSchema,
7928 req: &GenerateImageRequest,
7929 reservation: &mut resource_policy::LocalLoadReservation,
7930 ) -> Result<GenerateImageResult, InferenceError> {
7931 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7932 let size = backend_cache::estimate_model_size(&model_dir);
7933 let (handle, _retention) = Self::load_backend_healing(
7935 &schema.id,
7936 model_dir,
7937 &self.flux_cache,
7938 size,
7939 reservation,
7940 backend::mlx_flux::FluxBackend::load,
7941 || self.unified_registry.redownload_local(&schema.id),
7942 )
7943 .await?;
7944 let req = req.clone();
7949 let device_guard = Self::mlx_device_lock().lock_owned().await;
7950 tokio::task::spawn_blocking(move || -> Result<GenerateImageResult, InferenceError> {
7951 let _device_guard = device_guard;
7953 let mut guard = handle.lock().map_err(|_| {
7954 InferenceError::InferenceFailed("flux backend mutex poisoned".into())
7955 })?;
7956 guard.generate(&req)
7957 })
7958 .await
7959 .map_err(|e| InferenceError::InferenceFailed(format!("flux task join: {e}")))?
7960 }
7961
7962 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7964 async fn generate_video_native_mlx(
7965 &self,
7966 schema: &ModelSchema,
7967 req: &GenerateVideoRequest,
7968 reservation: &mut resource_policy::LocalLoadReservation,
7969 ) -> Result<GenerateVideoResult, InferenceError> {
7970 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7971 let size = backend_cache::estimate_model_size(&model_dir);
7972 let (handle, _retention) = Self::load_backend_healing(
7974 &schema.id,
7975 model_dir,
7976 &self.ltx_cache,
7977 size,
7978 reservation,
7979 backend::mlx_ltx::LtxBackend::load,
7980 || self.unified_registry.redownload_local(&schema.id),
7981 )
7982 .await?;
7983 let req = req.clone();
7984
7985 let device_guard = Self::mlx_device_lock().lock_owned().await;
8016
8017 tokio::task::spawn_blocking(move || -> Result<GenerateVideoResult, InferenceError> {
8018 let _device_guard = device_guard;
8021 let mut guard = handle.lock().map_err(|_| {
8022 InferenceError::InferenceFailed("ltx backend mutex poisoned".into())
8023 })?;
8024 guard.generate(&req)
8025 })
8026 .await
8027 .map_err(|e| InferenceError::InferenceFailed(format!("ltx task join: {e}")))?
8028 }
8029
8030 pub async fn generate_video(
8032 &self,
8033 req: GenerateVideoRequest,
8034 ) -> Result<GenerateVideoResult, InferenceError> {
8035 if let Err(msg) = req.validate() {
8038 return Err(InferenceError::InferenceFailed(format!(
8039 "invalid GenerateVideoRequest: {}",
8040 msg
8041 )));
8042 }
8043 #[allow(unused_variables)]
8045 let requires_audio_conditioning = req.requires_audio_passthrough_opt_in();
8046 let candidates = self
8049 .media_generation_candidates(ModelCapability::VideoGeneration, req.model.as_deref())?;
8050 let mut last_error = None;
8051
8052 for schema in candidates {
8053 let mut reservation = self.reserve_local_request(&schema, 0)?;
8055 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
8056 reservation
8057 .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
8058 .map_err(InferenceError::from)?;
8059 let result = match &schema.source {
8060 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
8061 ModelSource::Mlx { hf_repo, .. } => {
8062 let use_external_ltx = requires_audio_conditioning;
8076 if requires_audio_conditioning && !crate::backend::external_ltx::is_available()
8077 {
8078 return Err(InferenceError::InferenceFailed(
8079 "audio-reference video conditioning requires the external `ltx-2-mlx a2v` CLI on PATH"
8080 .to_string(),
8081 ));
8082 }
8083 if use_external_ltx {
8084 let mut req = req.clone();
8085 req.model = Some(hf_repo.clone());
8086 crate::backend::external_ltx::generate_video(&req)
8087 } else {
8088 self.generate_video_native_mlx(&schema, &req, &mut reservation)
8089 .await
8090 }
8091 }
8092 _ => Err(InferenceError::InferenceFailed(format!(
8093 "video generation not implemented for model source: {}",
8094 schema.id
8095 ))),
8096 };
8097
8098 match result {
8099 Ok(result) => return Ok(result),
8100 Err(err) if req.model.is_some() => return Err(err),
8101 Err(err) => last_error = Some(err),
8102 }
8103 }
8104
8105 Err(last_error.unwrap_or_else(|| {
8106 InferenceError::InferenceFailed("no video generation models available".into())
8107 }))
8108 }
8109
8110 pub fn response_format_rejection_reason(
8118 &self,
8119 model: &str,
8120 rf: &crate::tasks::generate::ResponseFormat,
8121 ) -> Option<String> {
8122 let registry = self.unified_registry.clone();
8123 let schema = registry
8124 .list()
8125 .into_iter()
8126 .find(|s| s.id == model || s.name == model)?;
8127 match &schema.source {
8128 ModelSource::Proprietary { .. } => {
8129 Some("the Parslee gateway does not accept response_format".to_string())
8130 }
8131 ModelSource::RemoteApi { protocol, .. } => {
8132 let handler = crate::protocol::handler_for(*protocol);
8133 (!handler.supports_response_format(rf)).then(|| {
8134 format!(
8135 "the {} protocol rejects response_format",
8136 handler.protocol_name()
8137 )
8138 })
8139 }
8140 _ => None,
8141 }
8142 }
8143
8144 pub fn list_models_unified(&self) -> Vec<ModelInfo> {
8149 let hardware = HardwareInfo::detect();
8150 let policy = self.active_local_resource_policy();
8151 self.list_models_unified_for(&hardware, &policy.policy)
8152 }
8153
8154 pub fn model_fit(&self, schema: &ModelSchema) -> recommend::ModelFit {
8158 recommend::model_fit(
8159 schema,
8160 &HardwareInfo::detect(),
8161 Some(&self.active_local_resource_policy().policy),
8162 )
8163 }
8164
8165 pub fn list_models_unified_for(
8169 &self,
8170 hardware: &HardwareInfo,
8171 policy: &resource_policy::ResourcePolicy,
8172 ) -> Vec<ModelInfo> {
8173 let mut registry = self.unified_registry.clone();
8174 registry.prune_missing_on_disk_models();
8175 registry.refresh_availability();
8176 registry
8177 .list()
8178 .iter()
8179 .map(|schema| {
8180 let mut info = ModelInfo::from(*schema).with_fit(recommend::model_fit(
8181 schema,
8182 hardware,
8183 Some(policy),
8184 ));
8185 if schema.downloads_weights() {
8186 let enabled = self
8187 .model_management
8188 .car_enabled(&schema.id)
8189 .unwrap_or(false);
8190 if !enabled {
8191 info.available = false;
8195 }
8196 if enabled
8197 && self
8198 .model_management
8199 .load_receipt(&schema.id)
8200 .is_ok_and(|receipt| receipt.is_none())
8201 {
8202 if let Ok(_mutation) = self.model_management.begin_mutation(&schema.id) {
8209 if let Some(path) = registry.existing_local_artifact(&schema.id) {
8210 if std::fs::symlink_metadata(&path)
8211 .is_ok_and(|metadata| metadata.file_type().is_symlink())
8212 {
8213 let generation = self
8214 .resource_policy_generation
8215 .load(std::sync::atomic::Ordering::Acquire);
8216 let _ = self.model_management.record_managed_artifact(
8217 &schema.id,
8218 model_source_identity(schema),
8219 None,
8220 generation,
8221 true,
8222 path,
8223 );
8224 }
8225 }
8226 }
8227 }
8228 let receipt = self.model_management.load_receipt(&schema.id);
8229 let directory_cleanup_unsupported =
8230 !model_management::directory_removal_supported()
8231 && receipt.as_ref().is_ok_and(|receipt| {
8232 receipt.as_ref().is_some_and(|receipt| {
8233 receipt.artifact_kind
8234 == model_management::ManagedArtifactKind::Directory
8235 })
8236 });
8237 info.car_enabled = enabled;
8238 info.can_remove = enabled
8239 && self
8240 .model_management
8241 .can_remove(&schema.id)
8242 .unwrap_or(false);
8243 info.in_use = self.local_admission.active_request_count(&schema.id) > 0
8244 || self.local_admission.is_resident(&schema.id)
8245 || self.local_admission.teardown_pending(&schema.id)
8246 || self
8247 .model_management
8248 .model_in_use(&schema.id)
8249 .unwrap_or(true);
8250 info.management_evidence = if !enabled {
8251 Some("disabled_tombstone".into())
8252 } else if directory_cleanup_unsupported {
8253 Some("install_receipt_directory_cleanup_unsupported".into())
8254 } else if receipt.is_ok_and(|receipt| receipt.is_some()) {
8255 Some("install_receipt".into())
8256 } else if info.weights_ready {
8257 Some("shared_or_hand_installed".into())
8258 } else {
8259 None
8260 };
8261 }
8262 info
8263 })
8264 .collect()
8265 }
8266
8267 pub fn model_management_store(&self) -> &model_management::ModelManagementStore {
8268 &self.model_management
8269 }
8270
8271 fn ensure_model_enabled(&self, model_id: &str) -> Result<(), InferenceError> {
8272 if self.model_management.car_enabled(model_id)? {
8273 return Ok(());
8274 }
8275 Err(InferenceError::InferenceFailed(format!(
8276 "local model {model_id} was removed from CAR; reinstall it before use"
8277 )))
8278 }
8279
8280 pub fn available_model_upgrades(&self) -> Vec<ModelUpgrade> {
8282 self.unified_registry.available_upgrades()
8283 }
8284
8285 pub async fn check_upgrade_nudge(
8291 &self,
8292 inference_active: bool,
8293 ) -> (crate::nudge::NudgeDecision, crate::nudge::NudgeState) {
8294 let findings = self.detect_upgrades().await;
8295 let prefs = self.update_prefs();
8296 let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
8297 let now = std::time::SystemTime::now()
8298 .duration_since(std::time::UNIX_EPOCH)
8299 .map(|d| d.as_secs())
8300 .unwrap_or(0);
8301 let decision = crate::nudge::decide_nudge(
8302 &findings,
8303 &prefs,
8304 &state,
8305 now,
8306 crate::nudge::DEFAULT_THROTTLE_SECS,
8307 inference_active,
8308 );
8309 (decision, state)
8310 }
8311
8312 pub fn dismiss_upgrade_nudge(&self, dismiss_key: &str) -> Result<(), InferenceError> {
8315 let path = crate::nudge::NudgeState::default_path();
8316 let mut state = crate::nudge::NudgeState::load_from(&path);
8317 state.dismiss(dismiss_key);
8318 state
8319 .save_to(&path)
8320 .map_err(InferenceError::InferenceFailed)
8321 }
8322
8323 pub async fn check_concierge(
8330 &self,
8331 inference_active: bool,
8332 ) -> (
8333 Vec<crate::concierge::ConciergeSuggestion>,
8334 crate::nudge::NudgeState,
8335 ) {
8336 let prefs = self.update_prefs();
8337 let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
8338 let hw = crate::hardware::HardwareInfo::detect();
8339 let schemas = self.list_schemas();
8340 let refs: Vec<&ModelSchema> = schemas.iter().collect();
8341 let now = std::time::SystemTime::now()
8342 .duration_since(std::time::UNIX_EPOCH)
8343 .map(|d| d.as_secs())
8344 .unwrap_or(0);
8345 let suggestions = crate::concierge::decide_concierge(
8346 &refs,
8347 &hw,
8348 crate::concierge::DEFAULT_WATCHED_USE_CASES,
8349 crate::intent::QualityTier::Balanced,
8350 &prefs,
8351 &state,
8352 now,
8353 crate::concierge::DEFAULT_CONCIERGE_THROTTLE_SECS,
8354 inference_active,
8355 );
8356 (suggestions, state)
8357 }
8358
8359 pub fn dismiss_concierge_suggestion(&self, dismiss_key: &str) -> Result<(), InferenceError> {
8364 self.dismiss_upgrade_nudge(dismiss_key)
8365 }
8366
8367 pub fn dismiss_concierge_labeled(
8371 &self,
8372 dismiss_key: &str,
8373 reason: crate::concierge::DismissReason,
8374 ) -> Result<(), String> {
8375 let path = crate::nudge::NudgeState::default_path();
8376 let mut state = crate::nudge::NudgeState::load_from(&path);
8377 let now = std::time::SystemTime::now()
8378 .duration_since(std::time::UNIX_EPOCH)
8379 .map(|d| d.as_secs())
8380 .unwrap_or(0);
8381 state.dismiss_labeled(dismiss_key, reason, now);
8382 state.save_to(&path).map_err(|e| e.to_string())
8383 }
8384
8385 pub async fn check_canaries(&self) -> Vec<crate::intent::UseCase> {
8393 use crate::action_ledger::ConciergeActionKind;
8394 use crate::concierge::{
8395 canary_verdict, CanaryVerdict, CANARY_MIN_SAMPLES, CANARY_REGRESSION_MARGIN,
8396 };
8397 use std::collections::BTreeMap;
8398
8399 let actions = self.concierge_actions(0);
8400 let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
8401 let entries = crate::outcome::read_ledger(&ledger_path, 0);
8402
8403 let mut latest: BTreeMap<
8405 crate::intent::UseCase,
8406 &crate::action_ledger::ConciergeActionEntry,
8407 > = BTreeMap::new();
8408 for a in &actions {
8409 if a.project.is_some() {
8410 continue; }
8412 if let Some(uc) = a.use_case {
8413 latest.insert(uc, a);
8414 }
8415 }
8416
8417 let mut reverts: Vec<(crate::intent::UseCase, u64)> = Vec::new();
8422 let tracker = self.outcome_tracker.read().await;
8423 for (uc, a) in latest {
8424 if a.kind != ConciergeActionKind::SetDefault {
8427 continue;
8428 }
8429 let Some(prior) = a.prior_model_id.as_deref() else {
8430 continue; };
8432
8433 let (mut succ, mut total) = (0u64, 0u64);
8435 for e in &entries {
8436 if e.timestamp < a.timestamp
8437 || e.model_id != a.model_id
8438 || crate::usage_profile::use_case_for_task(e.task) != uc
8439 {
8440 continue;
8441 }
8442 match e.success {
8443 Some(true) => {
8444 succ += 1;
8445 total += 1;
8446 }
8447 Some(false) => total += 1,
8448 None => {}
8449 }
8450 }
8451 let new_rate = if total == 0 {
8452 None
8453 } else {
8454 Some(succ as f64 / total as f64)
8455 };
8456
8457 let (mut base_succ, mut base_total) = (0u64, 0u64);
8462 if let Some(profile) = tracker.profile(prior) {
8463 for t in [
8464 crate::outcome::InferenceTask::Generate,
8465 crate::outcome::InferenceTask::Embed,
8466 crate::outcome::InferenceTask::Classify,
8467 crate::outcome::InferenceTask::Code,
8468 crate::outcome::InferenceTask::Reasoning,
8469 ] {
8470 if crate::usage_profile::use_case_for_task(t) != uc {
8471 continue;
8472 }
8473 if let Some(ts) = profile.task_stats(t) {
8474 base_succ += ts.successes;
8475 base_total += ts.successes + ts.failures;
8476 }
8477 }
8478 }
8479 if base_total == 0 {
8482 continue;
8483 }
8484 let baseline = base_succ as f64 / base_total as f64;
8485
8486 if canary_verdict(
8487 new_rate,
8488 total,
8489 baseline,
8490 CANARY_MIN_SAMPLES,
8491 CANARY_REGRESSION_MARGIN,
8492 ) == CanaryVerdict::Revert
8493 {
8494 reverts.push((uc, a.seq));
8498 }
8499 }
8500 drop(tracker);
8501
8502 let mut reverted = Vec::new();
8505 for (uc, seq) in reverts {
8506 if self.rollback_lane_inner(uc, None, Some(seq)).await.is_ok() {
8507 tracing::info!(lane = ?uc, "concierge canary: auto-reverted a worse model switch");
8508 reverted.push(uc);
8509 }
8510 }
8511 reverted
8512 }
8513
8514 pub async fn concierge_ask(&self, question: &str) -> Result<String, String> {
8523 use std::fmt::Write as _;
8524 let status = self.concierge_status(false).await;
8525 let hw = crate::hardware::HardwareInfo::detect();
8526 let schemas = self.list_schemas();
8527 let refs: Vec<&ModelSchema> = schemas.iter().collect();
8528
8529 let mut evidence = String::new();
8530 evidence.push_str("OBSERVED USAGE (last 30 days):\n");
8531 if status.lanes.is_empty() {
8532 evidence.push_str(" (no usage recorded yet)\n");
8533 }
8534 for lane in &status.lanes {
8535 let rate = lane
8536 .success_rate()
8537 .map(|r| format!("{:.0}% success", r * 100.0))
8538 .unwrap_or_else(|| "no resolved signal".into());
8539 let _ = writeln!(
8540 evidence,
8541 " {:?}: {} calls, {}{}",
8542 lane.use_case,
8543 lane.calls,
8544 rate,
8545 if lane.failing_models.is_empty() {
8546 String::new()
8547 } else {
8548 format!(
8549 ", failing on {}",
8550 lane.failing_models
8551 .iter()
8552 .cloned()
8553 .collect::<Vec<_>>()
8554 .join(", ")
8555 )
8556 }
8557 );
8558 }
8559 evidence.push_str("\nMODEL HEALTH:\n");
8560 for m in &status.models {
8561 let success = match m.success_rate {
8562 Some(r) => format!("{:.0}% success", r * 100.0),
8563 None => "no resolved signal".to_string(),
8564 };
8565 let _ = writeln!(
8566 evidence,
8567 " {}: {} calls, {}, {:.0}ms avg{}",
8568 m.model_id,
8569 m.calls,
8570 success,
8571 m.avg_latency_ms,
8572 if m.excluded { " (excluded)" } else { "" }
8573 );
8574 }
8575 let mut menu_lanes: Vec<crate::intent::UseCase> =
8581 crate::concierge::DEFAULT_WATCHED_USE_CASES.to_vec();
8582 for lane in &status.lanes {
8583 if !menu_lanes.contains(&lane.use_case) {
8584 menu_lanes.push(lane.use_case);
8585 }
8586 }
8587 evidence.push_str("\nGROUNDED CANDIDATES (fit verified for this machine):\n");
8588 for uc in menu_lanes {
8589 let set = crate::recommend::recommend(
8590 &refs,
8591 &hw,
8592 uc,
8593 crate::intent::QualityTier::Balanced,
8594 crate::intent::Privacy::OnDevice,
8595 );
8596 for p in set.picks.iter().take(3) {
8597 let _ = writeln!(
8598 evidence,
8599 " [{:?}] {} — {}{}",
8600 uc,
8601 p.display_name,
8602 if p.already_installed {
8603 "installed"
8604 } else {
8605 "available"
8606 },
8607 if p.fit == crate::recommend::FitStatus::Fits {
8608 ", fits"
8609 } else {
8610 ", does NOT fit"
8611 }
8612 );
8613 }
8614 }
8615 if let Some(s) = &status.decision.suggestion {
8616 let _ = writeln!(evidence, "\nCURRENT SUGGESTION: {}", s.message);
8617 }
8618
8619 let prompt = format!(
8620 "You are CAR's model concierge. Answer the user's question ONLY from the \
8621 EVIDENCE provided as context — the user's observed model usage, model \
8622 health, and the grounded candidate menu (the only models you may \
8623 mention). NEVER invent a model name and NEVER claim a model fits or is \
8624 better than the evidence states. If the evidence doesn't answer the \
8625 question, say so plainly. Be concise and concrete.\n\nQUESTION: {question}"
8626 );
8627 let evidence_lc = evidence.to_lowercase();
8628 let req = crate::tasks::generate::GenerateRequest {
8629 prompt,
8630 context: Some(evidence),
8631 intent: Some(crate::intent::IntentHint {
8632 task: Some(crate::intent::TaskHint::Chat),
8633 prefer_local: true,
8634 ..Default::default()
8635 }),
8636 ..Default::default()
8637 };
8638 let answer = self.generate(req).await.map_err(|e| e.to_string())?;
8639
8640 const FAMILIES: [&str; 9] = [
8644 "llama", "gpt", "mistral", "gemma", "deepseek", "phi", "claude", "grok", "qwen",
8645 ];
8646 let answer_lc = answer.to_lowercase();
8647 let leaked = FAMILIES
8648 .iter()
8649 .any(|fam| answer_lc.contains(fam) && !evidence_lc.contains(fam));
8650 let answer = if leaked {
8651 format!(
8652 "{answer}\n\n(Note: I can only verify models in your catalog — any others \
8653 named above aren't checked for fit on your machine.)"
8654 )
8655 } else {
8656 answer
8657 };
8658 Ok(answer)
8659 }
8660
8661 pub async fn refresh_catalog(&self) -> Result<usize, String> {
8669 let url = std::env::var("CAR_CATALOG_URL")
8670 .map_err(|_| "no catalog source configured (set CAR_CATALOG_URL)".to_string())?;
8671 let pubkey = std::env::var("CAR_CATALOG_PUBKEY")
8672 .map_err(|_| "no catalog public key configured (set CAR_CATALOG_PUBKEY)".to_string())?;
8673 let http = crate::tls_client::catalog_refresh_client();
8678 let verified = crate::catalog::fetch_and_verify(&http, &url, &pubkey).await?;
8679 let path = crate::catalog::cache_path(&self.config.state_root);
8680 crate::catalog::install_if_newer(&path, &verified, &pubkey).await
8685 }
8686
8687 pub async fn discover_models(&self) -> Result<usize, String> {
8696 use crate::schema::{ModelSource, TrustTier};
8697 let template = self
8704 .unified_registry
8705 .all()
8706 .filter(|m| {
8707 m.provider.eq_ignore_ascii_case("openai")
8708 && m.trust_tier == TrustTier::Curated
8709 && matches!(m.source, ModelSource::RemoteApi { .. })
8710 })
8711 .max_by(|a, b| {
8712 a.capabilities
8713 .len()
8714 .cmp(&b.capabilities.len())
8715 .then_with(|| a.id.cmp(&b.id))
8716 })
8717 .cloned();
8718 let Some(template) = template else {
8719 return Ok(0); };
8721 let (endpoint, api_key_env) = match &template.source {
8722 ModelSource::RemoteApi {
8723 endpoint,
8724 api_key_env,
8725 ..
8726 } => (endpoint.clone(), api_key_env.clone()),
8727 _ => return Ok(0),
8728 };
8729 let Some(models_url) = crate::discovery::models_url_from_endpoint(&endpoint) else {
8730 return Ok(0);
8731 };
8732 let key = match car_secrets::resolve_env_or_keychain(&api_key_env) {
8733 Some(k) if !k.is_empty() => k,
8734 _ => return Ok(0), };
8736
8737 let http = reqwest::Client::builder()
8741 .timeout(std::time::Duration::from_secs(20))
8742 .build()
8743 .map_err(|e| format!("discovery client: {e}"))?;
8744 let ids = crate::discovery::fetch_model_ids(&http, &models_url, &key).await?;
8745
8746 let cache = crate::discovery::cache_path(&self.config.state_models_dir());
8748 let mut cached = crate::discovery::load_cache(&cache);
8749 let mut have: std::collections::HashSet<String> =
8750 cached.iter().map(|m| m.id.clone()).collect();
8751 for id in ids {
8752 if !crate::discovery::is_chat_model(&id) {
8753 continue;
8754 }
8755 let schema = crate::discovery::discovered_schema("openai", &id, &template);
8756 if self.unified_registry.get(&schema.id).is_some() {
8762 continue;
8763 }
8764 if have.insert(schema.id.clone()) {
8765 cached.push(schema);
8766 }
8767 }
8768 let count = cached.len();
8769 crate::discovery::save_cache(&cache, &cached)?;
8770 Ok(count)
8771 }
8772
8773 pub fn lane_defaults(&self) -> crate::lane_defaults::LaneDefaults {
8775 self.lane_defaults_cache.read().unwrap().clone()
8776 }
8777
8778 pub fn lane_default(
8782 &self,
8783 project: Option<&str>,
8784 use_case: crate::intent::UseCase,
8785 ) -> Option<String> {
8786 self.lane_defaults_cache
8787 .read()
8788 .unwrap()
8789 .resolve(project, use_case)
8790 .map(str::to_string)
8791 }
8792
8793 fn model_is_excluded(
8794 exclude_set: &std::collections::HashSet<String>,
8795 registry: &UnifiedRegistry,
8796 model: &str,
8797 ) -> bool {
8798 registry
8799 .get(model)
8800 .or_else(|| registry.find_by_name(model))
8801 .map(|schema| exclude_set.contains(&schema.id))
8802 .unwrap_or_else(|| exclude_set.contains(model))
8803 }
8804
8805 fn lane_pin_for(
8811 &self,
8812 req: &GenerateRequest,
8813 routing_registry: &UnifiedRegistry,
8814 ) -> Option<String> {
8815 let task = req.intent.as_ref().and_then(|h| h.task)?;
8816 let use_case = crate::usage_profile::use_case_for_task_hint(task);
8817 let id = self.lane_default(None, use_case)?;
8818 let known = routing_registry
8823 .get(&id)
8824 .or_else(|| routing_registry.find_by_name(&id));
8825 match known {
8826 Some(s) if s.available_now() => Some(id),
8827 _ => None,
8828 }
8829 }
8830
8831 pub fn set_lane_default(
8834 &self,
8835 project: Option<String>,
8836 use_case: crate::intent::UseCase,
8837 model_id: &str,
8838 ) -> Result<(), String> {
8839 let now = std::time::SystemTime::now()
8840 .duration_since(std::time::UNIX_EPOCH)
8841 .map(|d| d.as_secs())
8842 .unwrap_or(0);
8843 let mut defaults = self.lane_defaults_cache.write().unwrap();
8846 defaults.set(project, use_case, model_id.to_string(), now);
8847 crate::lane_defaults::save_to(&crate::lane_defaults::default_path(), &defaults)
8848 .map_err(|e| e.to_string())
8849 }
8850
8851 pub fn clear_lane_default(
8854 &self,
8855 project: Option<&str>,
8856 use_case: crate::intent::UseCase,
8857 ) -> Result<bool, String> {
8858 let mut defaults = self.lane_defaults_cache.write().unwrap();
8859 let removed = defaults.clear(project, use_case);
8860 crate::lane_defaults::save_to(&crate::lane_defaults::default_path(), &defaults)
8861 .map_err(|e| e.to_string())?;
8862 Ok(removed)
8863 }
8864
8865 pub async fn user_set_lane_default(
8873 &self,
8874 use_case: crate::intent::UseCase,
8875 model_id: &str,
8876 project: Option<String>,
8877 ) -> Result<(), String> {
8878 use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8879 let _guard = self.concierge_action_lock.lock().await;
8880 let prior = self.lane_default(project.as_deref(), use_case);
8881 self.set_lane_default(project.clone(), use_case, model_id)?;
8882 self.record_concierge_action(ConciergeActionEntry {
8883 seq: 0,
8884 kind: ConciergeActionKind::SetDefault,
8885 model_id: model_id.to_string(),
8886 use_case: Some(use_case),
8887 project,
8888 prior_model_id: prior,
8889 detail: "user set lane default".into(),
8890 timestamp: now_unix(),
8891 });
8892 Ok(())
8893 }
8894
8895 pub async fn user_clear_lane_default(
8900 &self,
8901 use_case: crate::intent::UseCase,
8902 project: Option<String>,
8903 ) -> Result<bool, String> {
8904 use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8905 let _guard = self.concierge_action_lock.lock().await;
8906 let prior = self.lane_default(project.as_deref(), use_case);
8907 let removed = self.clear_lane_default(project.as_deref(), use_case)?;
8908 if removed {
8909 self.record_concierge_action(ConciergeActionEntry {
8910 seq: 0,
8911 kind: ConciergeActionKind::ClearDefault,
8912 model_id: prior.clone().unwrap_or_default(),
8913 use_case: Some(use_case),
8914 project,
8915 prior_model_id: prior,
8916 detail: "user cleared lane default".into(),
8917 timestamp: now_unix(),
8918 });
8919 }
8920 Ok(removed)
8921 }
8922
8923 pub fn concierge_actions(
8925 &self,
8926 limit: usize,
8927 ) -> Vec<crate::action_ledger::ConciergeActionEntry> {
8928 crate::action_ledger::read_actions(&crate::action_ledger::default_path(), limit)
8929 }
8930
8931 fn record_concierge_action(&self, mut entry: crate::action_ledger::ConciergeActionEntry) {
8932 entry.seq = self
8933 .concierge_action_seq
8934 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8935 if let Err(e) =
8936 crate::action_ledger::append_action(&crate::action_ledger::default_path(), &entry)
8937 {
8938 tracing::debug!("record concierge action failed: {e}");
8939 }
8940 }
8941
8942 pub async fn apply_concierge(
8954 &self,
8955 use_case: crate::intent::UseCase,
8956 model_id: &str,
8957 project: Option<String>,
8958 ) -> Result<crate::action_ledger::ConciergeApplyResult, String> {
8959 use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8960 let now = || {
8961 std::time::SystemTime::now()
8962 .duration_since(std::time::UNIX_EPOCH)
8963 .map(|d| d.as_secs())
8964 .unwrap_or(0)
8965 };
8966 if let Some(schema) = self
8971 .list_schemas()
8972 .into_iter()
8973 .find(|s| s.id == model_id || s.name == model_id)
8974 {
8975 let serves = use_case
8976 .required_capabilities()
8977 .iter()
8978 .all(|c| schema.capabilities.contains(c));
8979 if !serves {
8980 return Err(format!(
8981 "model '{model_id}' does not serve the {use_case:?} lane"
8982 ));
8983 }
8984 }
8985
8986 self.pull_model(model_id).await.map_err(|e| e.to_string())?;
8991
8992 let _guard = self.concierge_action_lock.lock().await;
8995 let prior = self.lane_default(project.as_deref(), use_case);
8998 self.record_concierge_action(ConciergeActionEntry {
8999 seq: 0, kind: ConciergeActionKind::Install,
9001 model_id: model_id.to_string(),
9002 use_case: Some(use_case),
9003 project: project.clone(),
9004 prior_model_id: None,
9005 detail: "concierge apply: ensured model present".into(),
9006 timestamp: now(),
9007 });
9008
9009 self.set_lane_default(project.clone(), use_case, model_id)?;
9011 self.record_concierge_action(ConciergeActionEntry {
9012 seq: 0, kind: ConciergeActionKind::SetDefault,
9014 model_id: model_id.to_string(),
9015 use_case: Some(use_case),
9016 project: project.clone(),
9017 prior_model_id: prior.clone(),
9018 detail: "concierge apply: set lane default".into(),
9019 timestamp: now(),
9020 });
9021
9022 Ok(crate::action_ledger::ConciergeApplyResult {
9023 model_id: model_id.to_string(),
9024 use_case,
9025 installed: true,
9026 set_default: true,
9027 prior_model_id: prior,
9028 })
9029 }
9030
9031 pub async fn rollback_lane(
9036 &self,
9037 use_case: crate::intent::UseCase,
9038 project: Option<String>,
9039 ) -> Result<Option<String>, String> {
9040 self.rollback_lane_inner(use_case, project, None).await
9041 }
9042
9043 async fn rollback_lane_inner(
9049 &self,
9050 use_case: crate::intent::UseCase,
9051 project: Option<String>,
9052 expected_anchor_seq: Option<u64>,
9053 ) -> Result<Option<String>, String> {
9054 use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
9055 let _guard = self.concierge_action_lock.lock().await;
9056 let actions = self.concierge_actions(0);
9061 let anchor = actions.iter().rev().find(|a| {
9062 matches!(
9063 a.kind,
9064 ConciergeActionKind::SetDefault | ConciergeActionKind::Rollback
9065 ) && a.use_case == Some(use_case)
9066 && a.project == project
9067 });
9068 let set = match anchor {
9069 None => return Err("no prior set-default to roll back".into()),
9070 Some(a) if a.kind == ConciergeActionKind::Rollback => {
9071 return Err("already rolled back to the prior default; nothing to undo".into())
9072 }
9073 Some(a) => a,
9074 };
9075 if let Some(seq) = expected_anchor_seq {
9078 if set.seq != seq {
9079 return Err("lane default changed since the canary decision; not reverting".into());
9080 }
9081 }
9082 let prior = set.prior_model_id.clone();
9083 let now = std::time::SystemTime::now()
9084 .duration_since(std::time::UNIX_EPOCH)
9085 .map(|d| d.as_secs())
9086 .unwrap_or(0);
9087
9088 match &prior {
9089 Some(m) => self.set_lane_default(project.clone(), use_case, m)?,
9090 None => {
9091 self.clear_lane_default(project.as_deref(), use_case)?;
9092 }
9093 }
9094 let detail = match &prior {
9095 Some(_) => "concierge rollback: restored prior lane default",
9096 None => "concierge rollback: cleared lane default (no prior)",
9097 };
9098 self.record_concierge_action(ConciergeActionEntry {
9099 seq: 0, kind: ConciergeActionKind::Rollback,
9101 model_id: prior.clone().unwrap_or_default(),
9102 use_case: Some(use_case),
9103 project,
9104 prior_model_id: Some(set.model_id.clone()),
9105 detail: detail.into(),
9106 timestamp: now,
9107 });
9108 Ok(prior)
9109 }
9110
9111 pub async fn concierge_status(
9116 &self,
9117 inference_active: bool,
9118 ) -> crate::concierge::ConciergeStatus {
9119 const USAGE_WINDOW_SECS: u64 = 30 * 24 * 60 * 60;
9121
9122 let prefs = self.update_prefs();
9123 let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
9124 let hw = crate::hardware::HardwareInfo::detect();
9125 let schemas = self.list_schemas();
9126 let refs: Vec<&ModelSchema> = schemas.iter().collect();
9127 let now = std::time::SystemTime::now()
9128 .duration_since(std::time::UNIX_EPOCH)
9129 .map(|d| d.as_secs())
9130 .unwrap_or(0);
9131
9132 let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9133 let entries = crate::outcome::read_ledger(&ledger_path, 0);
9134 let usage =
9135 crate::usage_profile::UsageProfile::from_ledger(&entries, now, USAGE_WINDOW_SECS);
9136
9137 let decision = crate::concierge::evaluate_concierge(
9138 &refs,
9139 &hw,
9140 &usage,
9141 crate::intent::QualityTier::Balanced,
9142 &prefs,
9143 &state,
9144 now,
9145 crate::concierge::DEFAULT_CONCIERGE_THROTTLE_SECS,
9146 inference_active,
9147 );
9148
9149 let tracker = self.outcome_tracker.read().await;
9150 let models = tracker
9151 .export_profiles()
9152 .iter()
9153 .map(|p| crate::concierge::ModelHealth {
9154 model_id: p.model_id.clone(),
9155 calls: p.total_calls,
9156 success_rate: p.success_rate_resolved(),
9160 avg_latency_ms: p.avg_latency_ms(),
9161 quality: p.ema_quality,
9162 excluded: tracker.is_excluded(&p.model_id),
9163 })
9164 .collect();
9165 drop(tracker);
9166
9167 const STALE_VERIFY_SECS: u64 = 14 * 24 * 60 * 60;
9172 let actions = self.concierge_actions(0);
9173 let mut latest: std::collections::BTreeMap<
9174 crate::intent::UseCase,
9175 &crate::action_ledger::ConciergeActionEntry,
9176 > = std::collections::BTreeMap::new();
9177 for a in &actions {
9178 if a.project.is_none() {
9179 if let Some(uc) = a.use_case {
9180 latest.insert(uc, a);
9181 }
9182 }
9183 }
9184 let mut pending_verification = Vec::new();
9185 for (uc, a) in latest {
9186 if a.kind != crate::action_ledger::ConciergeActionKind::SetDefault {
9187 continue;
9188 }
9189 if now.saturating_sub(a.timestamp) < STALE_VERIFY_SECS {
9190 continue; }
9192 let resolved = entries
9193 .iter()
9194 .filter(|e| {
9195 e.timestamp >= a.timestamp
9196 && e.model_id == a.model_id
9197 && crate::usage_profile::use_case_for_task(e.task) == uc
9198 && e.success.is_some()
9199 })
9200 .count() as u64;
9201 if resolved < crate::concierge::CANARY_MIN_SAMPLES {
9202 pending_verification.push(crate::concierge::PendingVerification {
9203 use_case: uc,
9204 model_id: a.model_id.clone(),
9205 set_at: a.timestamp,
9206 resolved_samples: resolved,
9207 needed: crate::concierge::CANARY_MIN_SAMPLES,
9208 });
9209 }
9210 }
9211
9212 crate::concierge::ConciergeStatus {
9213 lanes: usage.active_lanes().into_iter().cloned().collect(),
9214 decision,
9215 models,
9216 pending_verification,
9217 }
9218 }
9219
9220 pub async fn detect_upgrades(&self) -> Vec<crate::upgrade::UpgradeFinding> {
9224 let prefs = self.update_prefs();
9225 let curated = self.unified_registry.available_upgrades();
9226 let schemas = self.list_schemas();
9227 let refs: Vec<&ModelSchema> = schemas.iter().collect();
9228 let probe = crate::upgrade::HuggingFaceProbe::new();
9229 let now = std::time::SystemTime::now()
9230 .duration_since(std::time::UNIX_EPOCH)
9231 .map(|d| d.as_secs())
9232 .unwrap_or(0);
9233 crate::upgrade::detect_upgrades(
9234 curated,
9235 &refs,
9236 &prefs,
9237 &probe,
9238 &crate::upgrade::UpgradeCache::default_path(),
9239 now,
9240 crate::upgrade::DEFAULT_TTL_SECS,
9241 )
9242 .await
9243 }
9244
9245 pub fn list_schemas(&self) -> Vec<ModelSchema> {
9248 self.catalog_registry_snapshot()
9249 .list()
9250 .into_iter()
9251 .cloned()
9252 .collect()
9253 }
9254
9255 pub fn catalog_snapshot(&self) -> Result<CatalogSnapshot, String> {
9259 CatalogSnapshot::new(self.unified_registry.list().into_iter().cloned())
9260 }
9261
9262 pub fn registered_schema(&self, id: &str) -> Option<ModelSchema> {
9267 self.unified_registry.registered_schema(id).cloned()
9268 }
9269
9270 pub fn list_models(&self) -> Vec<models::ModelInfo> {
9271 self.registry.list_models()
9272 }
9273
9274 pub fn knows_model(&self, name: &str) -> bool {
9287 self.model_schema(name).is_some()
9288 }
9289
9290 pub fn model_schema(&self, name: &str) -> Option<&ModelSchema> {
9308 self.unified_registry
9309 .get(name)
9310 .or_else(|| self.unified_registry.find_by_name(name))
9311 }
9312
9313 fn first_installed_local_model(&self, needs_tools: bool) -> Option<String> {
9326 let mut candidates: Vec<_> = self
9334 .unified_registry
9335 .all()
9336 .filter(|s| s.is_local() && !s.is_vllm_mlx())
9343 .filter(|s| !s.is_foundation_models() || s.available)
9359 .filter(|s| s.has_capability(ModelCapability::Generate))
9360 .filter(|s| !needs_tools || s.has_capability(ModelCapability::ToolUse))
9364 .filter(|s| self.model_management.car_enabled(&s.id).unwrap_or(false))
9365 .filter(|s| self.unified_registry.ready_without_download(&s.id) == Some(true))
9366 .collect();
9367 candidates.sort_by_key(|s| s.size_mb());
9369
9370 if let Some(avail) = crate::hardware::available_ram_mb() {
9382 const WORKING_RESERVE_MB: u64 = 2048;
9383 candidates.retain(|s| s.size_mb().saturating_add(WORKING_RESERVE_MB) <= avail);
9384 }
9385
9386 candidates.first().map(|s| s.name.clone())
9387 }
9388
9389 pub async fn pull_model(&self, name: &str) -> Result<std::path::PathBuf, InferenceError> {
9391 self.pull_model_with_progress(name, &crate::download::ProgressSink::none())
9392 .await
9393 }
9394
9395 pub async fn pull_model_with_progress(
9399 &self,
9400 name: &str,
9401 sink: &crate::download::ProgressSink,
9402 ) -> Result<std::path::PathBuf, InferenceError> {
9403 let schema = self
9404 .unified_registry
9405 .find_by_name(name)
9406 .or_else(|| self.unified_registry.get(name))
9407 .ok_or_else(|| InferenceError::ModelNotFound(name.to_string()))?;
9408 let _mutation = self.model_management.begin_mutation(&schema.id)?;
9409 if let Some(receipt) = self.model_management.load_receipt(&schema.id)? {
9410 let _ = self.model_management.can_remove(&schema.id)?;
9414 self.model_management.clear_tombstone(&schema.id)?;
9415 return Ok(receipt.managed_path);
9416 }
9417 if let Some(receipt) = self.model_management.resume_install_intent(&schema.id)? {
9418 return Ok(receipt.managed_path);
9419 }
9420 let expected_managed = self.model_management.models_dir().join(&schema.name);
9421 if std::fs::symlink_metadata(&expected_managed).is_ok() {
9422 return Err(InferenceError::InferenceFailed(format!(
9423 "model {} has a pre-existing unreceipted artifact at {}; use models.adopt or move it before pulling",
9424 schema.id,
9425 expected_managed.display()
9426 )));
9427 }
9428 let staging = self.model_management.create_install_staging(&schema.id)?;
9429 let installed = match self
9430 .unified_registry
9431 .ensure_local_with_progress_staged(&schema.id, sink, &staging)
9432 .await
9433 {
9434 Ok(installed) => installed,
9435 Err(error) => {
9436 self.model_management.discard_install_staging(&staging);
9437 return Err(error);
9438 }
9439 };
9440 let installed_is_staging = match (installed.canonicalize(), staging.canonicalize()) {
9441 (Ok(installed), Ok(staging)) => installed == staging,
9442 _ => false,
9443 };
9444 let generation = self
9445 .resource_policy_generation
9446 .load(std::sync::atomic::Ordering::Acquire);
9447 let managed_path = self.model_management.models_dir().join(&schema.name);
9448 let managed = if installed_is_staging {
9449 let receipt = self.model_management.install_receipt_for_publication(
9450 &schema.id,
9451 model_source_identity(schema),
9452 generation,
9453 false,
9454 managed_path,
9455 model_management::ManagedArtifactKind::Directory,
9456 &staging,
9457 )?;
9458 self.model_management
9459 .begin_install_intent(receipt, Some(&staging))?;
9460 self.model_management
9461 .publish_install_staging(&schema.id, &staging, &schema.name)?
9462 } else {
9463 self.model_management.discard_install_staging(&staging);
9464 let receipt = self.model_management.install_receipt_for_publication(
9465 &schema.id,
9466 model_source_identity(schema),
9467 generation,
9468 false,
9469 managed_path,
9470 model_management::ManagedArtifactKind::Symlink,
9471 &installed,
9472 )?;
9473 self.model_management.begin_install_intent(receipt, None)?;
9474 self.model_management
9475 .materialize_managed_projection(&schema.name, &installed)?
9476 };
9477 let receipt = self
9478 .model_management
9479 .resume_install_intent(&schema.id)?
9480 .ok_or_else(|| {
9481 InferenceError::InferenceFailed(format!(
9482 "model {} publication completed without a durable install intent",
9483 schema.id
9484 ))
9485 })?;
9486 debug_assert_eq!(receipt.managed_path, managed);
9487 Ok(receipt.managed_path)
9488 }
9489
9490 pub async fn adopt_model_into_car(
9494 &self,
9495 model_id: &str,
9496 ) -> Result<model_management::InstallReceipt, InferenceError> {
9497 let schema = self
9498 .unified_registry
9499 .get(model_id)
9500 .or_else(|| self.unified_registry.find_by_name(model_id))
9501 .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
9502 if !schema.is_local() || !schema.downloads_weights() {
9503 return Err(InferenceError::InferenceFailed(format!(
9504 "model {} is not a CAR-manageable local artifact",
9505 schema.id
9506 )));
9507 }
9508 let existing = self
9509 .unified_registry
9510 .existing_local_artifact(&schema.id)
9511 .ok_or_else(|| {
9512 InferenceError::InferenceFailed(format!(
9513 "model {} has no usable local artifact to adopt",
9514 schema.id
9515 ))
9516 })?;
9517 let _mutation = self.model_management.begin_mutation(&schema.id)?;
9518 let existing_is_managed_symlink = std::fs::symlink_metadata(&existing)
9519 .is_ok_and(|metadata| metadata.file_type().is_symlink())
9520 && existing.parent().is_some_and(|parent| {
9521 parent.canonicalize().ok() == self.model_management.models_dir().canonicalize().ok()
9522 });
9523 if let Some(receipt) = self.model_management.resume_install_intent(&schema.id)? {
9524 return Ok(receipt);
9525 }
9526 let generation = self
9527 .resource_policy_generation
9528 .load(std::sync::atomic::Ordering::Acquire);
9529 let managed = if existing_is_managed_symlink {
9530 existing
9531 } else {
9532 let managed = self.model_management.adopted_projection_path(&schema.id)?;
9533 let receipt = self.model_management.install_receipt_for_publication(
9534 &schema.id,
9535 model_source_identity(schema),
9536 generation,
9537 true,
9538 managed,
9539 model_management::ManagedArtifactKind::Symlink,
9540 &existing,
9541 )?;
9542 self.model_management.begin_install_intent(receipt, None)?;
9543 self.model_management
9544 .materialize_adopted_projection(&schema.id, &existing)?
9545 };
9546 if existing_is_managed_symlink {
9547 self.model_management
9548 .record_managed_artifact(
9549 &schema.id,
9550 model_source_identity(schema),
9551 None,
9552 generation,
9553 true,
9554 managed,
9555 )
9556 .map_err(InferenceError::from)
9557 } else {
9558 self.model_management
9559 .resume_install_intent(&schema.id)?
9560 .ok_or_else(|| {
9561 InferenceError::InferenceFailed(format!(
9562 "model {} adoption completed without a durable install intent",
9563 schema.id
9564 ))
9565 })
9566 }
9567 }
9568
9569 pub async fn remove_model_from_car(
9572 &self,
9573 model_id: &str,
9574 ) -> Result<model_management::RemoveFromCarResult, InferenceError> {
9575 let schema = self
9576 .unified_registry
9577 .get(model_id)
9578 .or_else(|| self.unified_registry.find_by_name(model_id))
9579 .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
9580 if !schema.is_local() || !schema.downloads_weights() {
9581 return Err(InferenceError::InferenceFailed(format!(
9582 "model {} is not a CAR-owned local artifact",
9583 schema.id
9584 )));
9585 }
9586 if self.model_management.load_receipt(&schema.id)?.is_none() {
9587 if self.model_management.car_enabled(&schema.id)? {
9588 return Err(model_management::ModelManagementError::MissingReceipt {
9589 model_id: schema.id.clone(),
9590 }
9591 .into());
9592 }
9593 let generation = self
9597 .resource_policy_generation
9598 .load(std::sync::atomic::Ordering::Acquire)
9599 .saturating_add(1);
9600 return self
9601 .model_management
9602 .begin_mutation(&schema.id)?
9603 .remove(generation)
9604 .map_err(InferenceError::from);
9605 }
9606 let mutation = self.model_management.begin_mutation(&schema.id)?;
9607 let _maintenance = self
9608 .prepare_local_model_removal(&schema.id)
9609 .await
9610 .map_err(|error| InferenceError::InferenceFailed(error.to_string()))?;
9611 let generation = self
9612 .resource_policy_generation
9613 .load(std::sync::atomic::Ordering::Acquire)
9614 .saturating_add(1);
9615 mutation.remove(generation).map_err(InferenceError::from)
9616 }
9617
9618 pub fn update_prefs(&self) -> crate::update_prefs::UpdatePreferences {
9623 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
9624 crate::update_prefs::UpdatePreferences::load_effective(&cwd).unwrap_or_default()
9625 }
9626
9627 pub fn set_update_prefs(
9629 &self,
9630 prefs: &crate::update_prefs::UpdatePreferences,
9631 ) -> Result<(), InferenceError> {
9632 prefs.save().map_err(InferenceError::InferenceFailed)
9633 }
9634
9635 #[deprecated(note = "use async remove_model_from_car for receipt-backed safe removal")]
9639 pub fn remove_model(&self, name: &str) -> Result<(), InferenceError> {
9640 Err(InferenceError::InferenceFailed(format!(
9641 "legacy removal for {name} is disabled; use async receipt-backed remove_model_from_car"
9642 )))
9643 }
9644
9645 pub fn register_model(&mut self, schema: ModelSchema) {
9651 self.unified_registry.register(schema);
9652 }
9653
9654 pub fn register_user_model(&mut self, schema: ModelSchema) {
9656 self.unified_registry.register_user_model(schema);
9657 }
9658
9659 pub async fn discover_vllm_mlx_models(&mut self) -> usize {
9662 let config = vllm_mlx::VllmMlxConfig::default();
9663 if !config.auto_discover {
9664 return 0;
9665 }
9666 vllm_mlx::discover_and_register(&config, &mut self.unified_registry).await
9667 }
9668
9669 pub fn outcome_tracker(&self) -> Arc<RwLock<OutcomeTracker>> {
9671 self.outcome_tracker.clone()
9672 }
9673
9674 async fn auto_save_outcomes(&self) {
9682 const OUTCOME_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
9683
9684 if crate::offload::is_offload_worker() {
9692 return;
9693 }
9694
9695 let due = {
9697 let last = self.last_outcome_flush.lock().unwrap();
9698 last.is_none_or(|t| t.elapsed() >= OUTCOME_FLUSH_INTERVAL)
9699 };
9700 if due {
9701 match self.persist_outcomes().await {
9702 Ok(did) => {
9703 if did {
9704 *self.last_outcome_flush.lock().unwrap() = Some(Instant::now());
9705 }
9706 }
9707 Err(e) => tracing::debug!("auto-save outcomes failed: {}", e),
9708 }
9709 }
9710
9711 if let Err(e) = self.save_key_pool_stats().await {
9712 tracing::debug!("auto-save key pool stats failed: {}", e);
9713 }
9714 }
9715
9716 async fn persist_outcomes(&self) -> Result<bool, std::io::Error> {
9722 const PENDING_TTL_SECS: u64 = 300;
9723 let entries = {
9727 let mut tracker = self.outcome_tracker.write().await;
9728 tracker.sweep_pending(PENDING_TTL_SECS);
9729 tracker.drain_ledger()
9730 };
9731 let mut did = false;
9732 let ledger_disabled = std::env::var_os("CAR_NO_OUTCOME_LEDGER").is_some();
9737 if !entries.is_empty() && !ledger_disabled {
9738 let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9739 let _guard = self.ledger_io_lock.lock().await;
9740 crate::outcome::append_ledger_entries(&ledger_path, &entries)?;
9741 did = true;
9742 }
9743 let profiles_path = self.config.state_models_dir().join("outcome_profiles.json");
9745 let wrote = {
9746 let mut tracker = self.outcome_tracker.write().await;
9747 tracker.save_if_dirty(&profiles_path)?
9748 };
9749 Ok(did || wrote)
9750 }
9751
9752 pub async fn save_outcomes(&self) -> Result<(), std::io::Error> {
9757 let tracker = self.outcome_tracker.read().await;
9758 let path = self.config.state_models_dir().join("outcome_profiles.json");
9759 tracker.save_to_file(&path)
9760 }
9761
9762 pub async fn flush_outcomes(&self) -> Result<bool, std::io::Error> {
9768 let did = self.persist_outcomes().await?;
9769 if did {
9770 *self.last_outcome_flush.lock().unwrap() = Some(Instant::now());
9771 }
9772 Ok(did)
9773 }
9774
9775 pub async fn prune_outcome_ledger(&self, max_entries: usize) -> std::io::Result<()> {
9778 let path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9779 let _guard = self.ledger_io_lock.lock().await;
9780 crate::outcome::prune_ledger(&path, max_entries)
9781 }
9782
9783 pub async fn save_key_pool_stats(&self) -> Result<(), std::io::Error> {
9785 let path = self.config.state_models_dir().join("key_pool_stats.json");
9786 self.remote_backend.key_pool.save_stats(&path).await
9787 }
9788
9789 pub async fn key_pool_stats(
9791 &self,
9792 ) -> std::collections::HashMap<String, Vec<key_pool::KeyStats>> {
9793 self.remote_backend.key_pool.all_stats().await
9794 }
9795
9796 pub async fn export_profiles(&self) -> Vec<ModelProfile> {
9798 let tracker = self.outcome_tracker.read().await;
9799 tracker.export_profiles()
9800 }
9801
9802 pub fn outcome_scoreboard(&self) -> crate::scoreboard::Scoreboard {
9810 let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9811 let entries = crate::outcome::read_ledger(&ledger_path, 0);
9812 crate::calibration::ShadowCalibration::from_ledger(&entries).emit();
9816 crate::scoreboard::Scoreboard::from_ledger(&entries, |id| {
9817 let s = self
9818 .unified_registry
9819 .get(id)
9820 .or_else(|| self.unified_registry.find_by_name(id))?;
9821 match (s.cost.input_per_mtok, s.cost.output_per_mtok) {
9822 (Some(input_per_mtok), Some(output_per_mtok)) => {
9826 Some(crate::scoreboard::PriceModel {
9827 input_per_mtok,
9828 output_per_mtok,
9829 cache: s.cache_rates(),
9830 is_estimate: !s.cost.pricing_tiers.is_empty(),
9831 })
9832 }
9833 _ => None,
9834 }
9835 })
9836 }
9837
9838 pub async fn import_profiles(&self, profiles: Vec<ModelProfile>) {
9840 let mut tracker = self.outcome_tracker.write().await;
9841 tracker.import_profiles(profiles);
9842 }
9843
9844 pub async fn prepare_speech_runtime(&self) -> Result<PathBuf, InferenceError> {
9867 match self.ensure_speech_runtime().await {
9868 Ok(runtime) => Ok(runtime.root),
9869 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
9870 Err(err) => {
9871 let root = speech_runtime_root_from_models_dir(&self.config.models_dir);
9872 tracing::warn!(
9873 error = %err,
9874 root = %root.display(),
9875 "managed speech runtime could not be provisioned; native MLX \
9876 backends still cover the default local models, but catalogued \
9877 checkpoints they cannot load will be unavailable"
9878 );
9879 std::fs::create_dir_all(&root)?;
9880 Ok(root)
9881 }
9882 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
9883 Err(err) => Err(err),
9884 }
9885 }
9886
9887 pub fn set_speech_policy(&mut self, policy: SpeechPolicy) {
9889 self.speech_policy = policy;
9890 }
9891
9892 pub fn set_routing_config(&mut self, config: RoutingConfig) {
9893 self.adaptive_router.set_config(config);
9894 }
9895
9896 pub async fn install_curated_speech(
9898 &mut self,
9899 ) -> Result<Vec<SpeechInstallReport>, InferenceError> {
9900 if let Err(error) = self.prepare_speech_runtime().await {
9913 tracing::warn!(
9914 %error,
9915 "managed speech runtime could not be provisioned; installing the \
9916 models that do not depend on it"
9917 );
9918 }
9919 let schemas = self.list_schemas();
9920 let mut repos = Vec::new();
9921 for schema in &schemas {
9922 if !schema.is_mlx() || !schema.tags.iter().any(|tag| tag == "speech") {
9923 continue;
9924 }
9925 if !schema.available {
9931 continue;
9932 }
9933 if let ModelSource::Mlx { hf_repo, .. } = &schema.source {
9934 if !repos.iter().any(|existing: &String| existing == hf_repo) {
9935 repos.push(hf_repo.clone());
9936 }
9937 }
9938 }
9939
9940 let mut installed = Vec::new();
9941 for repo in repos {
9942 let (snapshot_path, files_downloaded) = download_hf_repo_snapshot(&repo).await?;
9943 let name = schemas
9944 .iter()
9945 .find(|schema| {
9946 matches!(&schema.source, ModelSource::Mlx { hf_repo, .. } if hf_repo == &repo)
9947 })
9948 .map(|schema| schema.name.clone())
9949 .unwrap_or_else(|| repo.clone());
9950 installed.push(SpeechInstallReport {
9951 name,
9952 hf_repo: repo,
9953 snapshot_path,
9954 files_downloaded,
9955 });
9956 }
9957
9958 for schema in &schemas {
9962 if !schema.tags.iter().any(|tag| tag == "speech") {
9963 continue;
9964 }
9965 if let ModelSource::WhisperCpp { model } = &schema.source {
9966 let model = model.clone();
9967 let name = schema.name.clone();
9968 let path = tokio::task::spawn_blocking(move || car_whisper::ensure_model(&model))
9969 .await
9970 .map_err(|e| InferenceError::InferenceFailed(format!("whisper join: {e}")))?
9971 .map_err(|e| {
9972 InferenceError::InferenceFailed(format!("whisper model fetch: {e}"))
9973 })?;
9974 installed.push(SpeechInstallReport {
9975 name,
9976 hf_repo: "ggerganov/whisper.cpp".to_string(),
9977 snapshot_path: path,
9978 files_downloaded: 1,
9979 });
9980 }
9981 }
9982
9983 self.unified_registry.refresh_availability();
9984 Ok(installed)
9985 }
9986
9987 pub fn speech_health(&self) -> SpeechHealthReport {
9989 let local_stt_default =
9990 self.speech_health_default_name(ModelCapability::SpeechToText, true, false);
9991 let local_tts_default =
9992 self.speech_health_default_name(ModelCapability::TextToSpeech, true, false);
9993 let remote_stt_default =
9994 self.speech_health_default_name(ModelCapability::SpeechToText, false, true);
9995 let remote_tts_default =
9996 self.speech_health_default_name(ModelCapability::TextToSpeech, false, true);
9997
9998 let mut local_models = Vec::new();
9999 let mut remote_models = Vec::new();
10000 for schema in self.list_schemas() {
10001 let capability = if schema.has_capability(ModelCapability::SpeechToText) {
10002 Some(ModelCapability::SpeechToText)
10003 } else if schema.has_capability(ModelCapability::TextToSpeech) {
10004 Some(ModelCapability::TextToSpeech)
10005 } else {
10006 None
10007 };
10008 let Some(capability) = capability else {
10009 continue;
10010 };
10011
10012 let selected_by_default = local_stt_default
10013 .as_ref()
10014 .is_some_and(|name| name == &schema.name)
10015 || local_tts_default
10016 .as_ref()
10017 .is_some_and(|name| name == &schema.name)
10018 || remote_stt_default
10019 .as_ref()
10020 .is_some_and(|name| name == &schema.name)
10021 || remote_tts_default
10022 .as_ref()
10023 .is_some_and(|name| name == &schema.name);
10024
10025 let health = SpeechModelHealth {
10026 id: schema.id.clone(),
10027 name: schema.name.clone(),
10028 provider: schema.provider.clone(),
10029 capability,
10030 is_local: schema.is_local(),
10031 available: schema.available,
10032 cached: speech_model_cached(&schema),
10033 selected_by_default,
10034 source: speech_model_source_label(&schema),
10035 };
10036 if schema.is_local() {
10037 local_models.push(health);
10038 } else {
10039 remote_models.push(health);
10040 }
10041 }
10042
10043 let runtime = {
10050 let rt =
10051 SpeechRuntime::new(speech_runtime_root_from_models_dir(&self.config.models_dir));
10052 SpeechRuntimeHealth {
10053 root: rt.root.clone(),
10054 installed: rt.is_ready(),
10055 python: rt.python.clone(),
10056 stt_command: rt.stt_program.clone(),
10057 tts_command: rt.tts_program.clone(),
10058 configured_python: std::env::var("CAR_SPEECH_PYTHON")
10059 .ok()
10060 .filter(|value| !value.trim().is_empty()),
10061 detected_python: detect_speech_python(),
10062 }
10063 };
10064
10065 SpeechHealthReport {
10066 runtime,
10067 local_models,
10068 remote_models,
10069 elevenlabs_configured: crate::tasks::transcribe::provider_configured_for_passive_status(
10073 "ELEVENLABS_API_KEY",
10074 )
10075 || crate::tasks::synthesize::provider_configured_for_passive_status(
10076 "ELEVENLABS_API_KEY",
10077 ),
10078 prefer_local: self.speech_policy.prefer_local,
10079 allow_remote_fallback: self.speech_policy.allow_remote_fallback,
10080 preferred_local_stt: self.speech_policy.preferred_local_stt.clone(),
10081 preferred_local_tts: self.speech_policy.preferred_local_tts.clone(),
10082 preferred_remote_stt: self.speech_policy.preferred_remote_stt.clone(),
10083 preferred_remote_tts: self.speech_policy.preferred_remote_tts.clone(),
10084 local_stt_default,
10085 local_tts_default,
10086 remote_stt_default,
10087 remote_tts_default,
10088 }
10089 }
10090
10091 pub async fn model_health(&self) -> ModelHealthReport {
10094 let schemas = self.list_schemas();
10095 let total_models = schemas.len();
10096 let available_models = schemas
10097 .iter()
10098 .filter(|schema| schema.available_now())
10099 .count();
10100 let local_models = schemas.iter().filter(|schema| schema.is_local()).count();
10101 let remote_models = total_models.saturating_sub(local_models);
10102
10103 let defaults = vec![
10104 self.model_default_health(
10105 ModelCapability::Generate,
10106 self.preferred_model_for_capability(ModelCapability::Generate)
10107 .unwrap_or(&self.config.generation_model),
10108 ),
10109 self.model_default_health(
10110 ModelCapability::Embed,
10111 self.preferred_model_for_capability(ModelCapability::Embed)
10112 .unwrap_or(&self.config.embedding_model),
10113 ),
10114 self.model_default_health(
10115 ModelCapability::Classify,
10116 self.preferred_model_for_capability(ModelCapability::Classify)
10117 .unwrap_or(&self.config.classification_model),
10118 ),
10119 ];
10120
10121 let mut providers = std::collections::BTreeMap::new();
10122 for schema in &schemas {
10123 let entry =
10124 providers
10125 .entry(schema.provider.clone())
10126 .or_insert_with(|| ProviderAccumulator {
10127 configured: false,
10128 local_models: 0,
10129 remote_models: 0,
10130 available_models: 0,
10131 capabilities: std::collections::HashSet::new(),
10132 });
10133
10134 entry.configured |= model_source_configured(schema);
10135 if schema.is_local() {
10136 entry.local_models += 1;
10137 } else {
10138 entry.remote_models += 1;
10139 }
10140 if schema.available_now() {
10141 entry.available_models += 1;
10142 }
10143 for capability in &schema.capabilities {
10144 entry.capabilities.insert(*capability);
10145 }
10146 }
10147
10148 let providers = providers
10149 .into_iter()
10150 .map(|(provider, acc)| ModelProviderHealth {
10151 provider,
10152 configured: acc.configured,
10153 local_models: acc.local_models,
10154 remote_models: acc.remote_models,
10155 available_models: acc.available_models,
10156 capabilities: sort_capabilities(acc.capabilities.into_iter().collect()),
10157 })
10158 .collect();
10159
10160 let capabilities = all_model_capabilities()
10161 .into_iter()
10162 .map(|capability| {
10163 let relevant: Vec<&ModelSchema> = schemas
10164 .iter()
10165 .filter(|schema| schema.has_capability(capability))
10166 .collect();
10167 let available: Vec<&ModelSchema> = relevant
10168 .iter()
10169 .copied()
10170 .filter(|schema| schema.available_now())
10171 .collect();
10172 ModelCapabilityHealth {
10173 capability,
10174 total_models: relevant.len(),
10175 available_models: available.len(),
10176 local_available_models: available
10177 .iter()
10178 .filter(|schema| schema.is_local())
10179 .count(),
10180 remote_available_models: available
10181 .iter()
10182 .filter(|schema| !schema.is_local())
10183 .count(),
10184 }
10185 })
10186 .collect();
10187
10188 let routing = self.routing_scenarios().await;
10189 let routing_config = self.adaptive_router.config().clone();
10190 let benchmark_priors =
10191 load_benchmark_prior_health(&self.config.state_models_dir(), &schemas);
10192
10193 ModelHealthReport {
10194 total_models,
10195 available_models,
10196 local_models,
10197 remote_models,
10198 defaults,
10199 providers,
10200 capabilities,
10201 routing_prefer_local: routing_config.prefer_local,
10202 routing_quality_first_cold_start: routing_config.quality_first_cold_start,
10203 routing_min_observations: routing_config.min_observations,
10204 routing_bootstrap_min_task_observations: routing_config.bootstrap_min_task_observations,
10205 routing_bootstrap_quality_floor: routing_config.bootstrap_quality_floor,
10206 routing_quality_weight: routing_config.quality_weight,
10207 routing_latency_weight: routing_config.latency_weight,
10208 routing_cost_weight: routing_config.cost_weight,
10209 routing_scenarios: routing,
10210 benchmark_priors,
10211 speech: self.speech_health(),
10212 }
10213 }
10214
10215 async fn routing_scenarios(&self) -> Vec<RoutingScenarioHealth> {
10216 let tracker = self.outcome_tracker.read().await;
10217 let config = self.adaptive_router.config().clone();
10218 let scenarios = [
10219 (
10220 "interactive_text",
10221 "Summarize the benefits of local-first AI routing in two sentences.",
10222 "text",
10223 RoutingWorkload::Interactive,
10224 false,
10225 false,
10226 ),
10227 (
10228 "background_code",
10229 "Write a Python function named fibonacci(n) that returns the nth Fibonacci number.",
10230 "code",
10231 RoutingWorkload::Background,
10232 false,
10233 false,
10234 ),
10235 (
10236 "interactive_tool_use",
10237 "Use the provided weather tool to get the weather for Boston.",
10238 "tool_use",
10239 RoutingWorkload::Interactive,
10240 true,
10241 false,
10242 ),
10243 (
10244 "interactive_vision",
10245 "What is in this image? Answer in one word.",
10246 "vision",
10247 RoutingWorkload::Interactive,
10248 false,
10249 true,
10250 ),
10251 ];
10252
10253 let routing_registry = self.catalog_registry_snapshot();
10260
10261 scenarios
10262 .into_iter()
10263 .map(
10264 |(name, prompt, task_family, workload, has_tools, has_vision)| {
10265 let decision = self.adaptive_router.route_context_aware(
10266 prompt,
10267 0,
10268 &routing_registry,
10269 &tracker,
10270 has_tools,
10271 has_vision,
10272 workload,
10273 );
10274 let quality_first_cold_start = if has_tools || has_vision {
10275 config.quality_first_cold_start
10276 } else if task_family == "code"
10277 && matches!(workload, RoutingWorkload::Background)
10278 {
10279 false
10280 } else {
10281 config.quality_first_cold_start
10282 };
10283 RoutingScenarioHealth {
10284 name: name.to_string(),
10285 task_family: task_family.to_string(),
10286 workload,
10287 has_tools,
10288 has_vision,
10289 prefer_local: if task_family == "speech" {
10290 self.speech_policy.prefer_local
10291 } else {
10292 config.prefer_local
10293 },
10294 quality_first_cold_start,
10295 bootstrap_min_task_observations: config.bootstrap_min_task_observations,
10296 bootstrap_quality_floor: config.bootstrap_quality_floor,
10297 model_id: decision.model_id,
10298 model_name: decision.model_name,
10299 reason: decision.reason,
10300 strategy: decision.strategy,
10301 }
10302 },
10303 )
10304 .collect()
10305 }
10306
10307 pub async fn smoke_test_speech(
10309 &self,
10310 local: bool,
10311 remote: bool,
10312 ) -> Result<SpeechSmokeReport, InferenceError> {
10313 let mut report = SpeechSmokeReport::default();
10314
10315 if local {
10316 let tts = self
10317 .preferred_speech_schema(ModelCapability::TextToSpeech, true, false)
10318 .ok_or_else(|| {
10319 InferenceError::InferenceFailed(
10320 "no local text-to-speech model available".into(),
10321 )
10322 })?;
10323 let stt = self
10324 .preferred_speech_schema(ModelCapability::SpeechToText, true, false)
10325 .ok_or_else(|| {
10326 InferenceError::InferenceFailed(
10327 "no local speech-to-text model available".into(),
10328 )
10329 })?;
10330 report.local = Some(
10331 self.run_speech_smoke_path("local", &tts, &stt, "Testing CAR local speech path.")
10332 .await?,
10333 );
10334 } else {
10335 report.skipped.push("local".to_string());
10336 }
10337
10338 if remote {
10339 let tts = self
10340 .preferred_speech_schema(ModelCapability::TextToSpeech, false, true)
10341 .ok_or_else(|| {
10342 InferenceError::InferenceFailed(
10343 "no remote text-to-speech model available".into(),
10344 )
10345 })?;
10346 let stt = self
10347 .preferred_speech_schema(ModelCapability::SpeechToText, false, true)
10348 .ok_or_else(|| {
10349 InferenceError::InferenceFailed(
10350 "no remote speech-to-text model available".into(),
10351 )
10352 })?;
10353 report.remote = Some(
10354 self.run_speech_smoke_path("remote", &tts, &stt, "Testing CAR remote speech path.")
10355 .await?,
10356 );
10357 } else {
10358 report.skipped.push("remote".to_string());
10359 }
10360
10361 Ok(report)
10362 }
10363
10364 fn speech_candidates(
10365 &self,
10366 capability: ModelCapability,
10367 explicit: Option<&str>,
10368 ) -> Result<Vec<ModelSchema>, InferenceError> {
10369 if let Some(model) = explicit {
10370 let schema = self
10371 .unified_registry
10372 .get(model)
10373 .or_else(|| self.unified_registry.find_by_name(model))
10374 .cloned()
10375 .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?;
10376 if !schema.has_capability(capability) {
10377 return Err(InferenceError::InferenceFailed(format!(
10378 "model {} does not support {:?}",
10379 schema.name, capability
10380 )));
10381 }
10382 return Ok(vec![schema]);
10383 }
10384
10385 let mut candidates: Vec<ModelSchema> = self
10386 .unified_registry
10387 .query(&ModelFilter {
10388 capabilities: vec![capability],
10389 ..Default::default()
10390 })
10391 .into_iter()
10392 .cloned()
10393 .collect();
10394
10395 if candidates.is_empty() {
10396 return Err(InferenceError::InferenceFailed(format!(
10397 "no models registered for capability {:?}",
10398 capability
10399 )));
10400 }
10401
10402 candidates.sort_by_key(|model| self.speech_sort_key(capability, model));
10403 if !self.speech_policy.allow_remote_fallback
10404 && candidates.iter().any(|model| model.is_local())
10405 {
10406 candidates.retain(|model| model.is_local());
10407 }
10408
10409 Ok(candidates)
10410 }
10411
10412 #[allow(dead_code)] fn resolve_external_hf_repo(
10418 &self,
10419 explicit: Option<&str>,
10420 capability: ModelCapability,
10421 ) -> Option<String> {
10422 let id = explicit?;
10423 let schema = self
10424 .unified_registry
10425 .get(id)
10426 .or_else(|| self.unified_registry.find_by_name(id))?;
10427 if !schema.has_capability(capability) {
10428 return Some(id.to_string());
10429 }
10430 if let ModelSource::Mlx { hf_repo, .. } = &schema.source {
10431 return Some(hf_repo.clone());
10432 }
10433 Some(id.to_string())
10434 }
10435
10436 fn media_generation_candidates(
10437 &self,
10438 capability: ModelCapability,
10439 explicit: Option<&str>,
10440 ) -> Result<Vec<ModelSchema>, InferenceError> {
10441 if let Some(model) = explicit {
10442 let schema = self
10443 .unified_registry
10444 .get(model)
10445 .or_else(|| self.unified_registry.find_by_name(model))
10446 .cloned()
10447 .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?;
10448 if !schema.has_capability(capability) {
10449 return Err(InferenceError::InferenceFailed(format!(
10450 "model {} does not support {:?}",
10451 schema.name, capability
10452 )));
10453 }
10454 return Ok(vec![schema]);
10455 }
10456
10457 let mut candidates: Vec<ModelSchema> = self
10458 .unified_registry
10459 .query(&ModelFilter {
10460 capabilities: vec![capability],
10461 local_only: true,
10462 ..Default::default()
10463 })
10464 .into_iter()
10465 .cloned()
10466 .collect();
10467 candidates.sort_by_key(|schema| (!schema.available, schema.size_mb()));
10468 if candidates.is_empty() {
10469 return Err(InferenceError::InferenceFailed(format!(
10470 "no models registered for capability {:?}",
10471 capability
10472 )));
10473 }
10474 Ok(candidates)
10475 }
10476
10477 fn preferred_speech_schema(
10478 &self,
10479 capability: ModelCapability,
10480 local_only: bool,
10481 remote_only: bool,
10482 ) -> Option<ModelSchema> {
10483 let available_only = remote_only;
10484 let mut candidates: Vec<ModelSchema> = self
10485 .unified_registry
10486 .query(&ModelFilter {
10487 capabilities: vec![capability],
10488 available_only,
10489 ..Default::default()
10490 })
10491 .into_iter()
10492 .filter(|schema| {
10493 (!local_only || schema.is_local()) && (!remote_only || schema.is_remote())
10494 })
10495 .cloned()
10496 .collect();
10497 candidates.sort_by_key(|model| self.speech_sort_key(capability, model));
10498 candidates.into_iter().next()
10499 }
10500
10501 fn speech_health_default_name(
10502 &self,
10503 capability: ModelCapability,
10504 local_only: bool,
10505 remote_only: bool,
10506 ) -> Option<String> {
10507 let preferred = match capability {
10508 ModelCapability::SpeechToText if local_only => {
10509 self.speech_policy.preferred_local_stt.as_ref()
10510 }
10511 ModelCapability::SpeechToText if remote_only => {
10512 self.speech_policy.preferred_remote_stt.as_ref()
10513 }
10514 ModelCapability::TextToSpeech if local_only => {
10515 self.speech_policy.preferred_local_tts.as_ref()
10516 }
10517 ModelCapability::TextToSpeech if remote_only => {
10518 self.speech_policy.preferred_remote_tts.as_ref()
10519 }
10520 _ => None,
10521 };
10522
10523 preferred
10524 .filter(|name| {
10525 self.unified_registry.list().iter().any(|schema| {
10526 schema.name == **name
10527 && schema.has_capability(capability)
10528 && (!local_only || schema.is_local())
10529 && (!remote_only || schema.is_remote())
10530 })
10531 })
10532 .cloned()
10533 .or_else(|| {
10534 self.preferred_speech_schema(capability, local_only, remote_only)
10535 .map(|schema| schema.name)
10536 })
10537 }
10538
10539 fn model_default_health(
10540 &self,
10541 capability: ModelCapability,
10542 configured_model: &str,
10543 ) -> ModelDefaultHealth {
10544 let schema = self
10545 .unified_registry
10546 .find_by_name(configured_model)
10547 .or_else(|| self.unified_registry.get(configured_model));
10548
10549 ModelDefaultHealth {
10550 capability,
10551 configured_model: configured_model.to_string(),
10552 available: schema.is_some_and(ModelSchema::available_now),
10553 is_local: schema.is_some_and(ModelSchema::is_local),
10554 provider: schema.map(|model| model.provider.clone()),
10555 }
10556 }
10557
10558 fn speech_sort_key(
10559 &self,
10560 capability: ModelCapability,
10561 model: &ModelSchema,
10562 ) -> (u8, u8, u8, u8, u64, u64) {
10563 let policy_preference = match capability {
10564 ModelCapability::SpeechToText if model.is_local() => {
10565 self.speech_policy.preferred_local_stt.as_ref()
10566 }
10567 ModelCapability::SpeechToText => self.speech_policy.preferred_remote_stt.as_ref(),
10568 ModelCapability::TextToSpeech if model.is_local() => {
10569 self.speech_policy.preferred_local_tts.as_ref()
10570 }
10571 ModelCapability::TextToSpeech => self.speech_policy.preferred_remote_tts.as_ref(),
10572 _ => None,
10573 };
10574 let local_rank = if self.speech_policy.prefer_local {
10575 if model.is_local() {
10576 0
10577 } else {
10578 1
10579 }
10580 } else if model.is_remote() {
10581 0
10582 } else {
10583 1
10584 };
10585 let availability_rank = if model.available {
10586 0
10587 } else if model.is_local() {
10588 1
10589 } else {
10590 2
10591 };
10592 let policy_rank: u8 = if policy_preference.is_some_and(|preferred| preferred == &model.name)
10593 {
10594 0
10595 } else {
10596 1
10597 };
10598 let speech_rank = match capability {
10599 ModelCapability::TextToSpeech => {
10614 if model.name == "Kokoro-82M-bf16" {
10615 0
10616 } else if model.name == "Kokoro-82M-6bit" {
10617 1
10618 } else if model.name == "Qwen3-TTS-12Hz-1.7B-Base-5bit" {
10619 3
10622 } else {
10623 2
10624 }
10625 }
10626 ModelCapability::SpeechToText => {
10627 if model.name == "Parakeet-TDT-0.6B-v3-MLX" {
10628 0
10629 } else {
10630 1
10631 }
10632 }
10633 _ => 0,
10634 };
10635 let latency_rank = model.performance.latency_p50_ms.unwrap_or(u64::MAX);
10636 let size_rank = model.cost.size_mb.unwrap_or(u64::MAX);
10637 (
10638 local_rank,
10639 availability_rank,
10640 policy_rank,
10641 speech_rank,
10642 latency_rank,
10643 size_rank,
10644 )
10645 }
10646
10647 async fn run_speech_smoke_path(
10648 &self,
10649 path: &str,
10650 tts: &ModelSchema,
10651 stt: &ModelSchema,
10652 text: &str,
10653 ) -> Result<SpeechSmokePathReport, InferenceError> {
10654 let work_dir = temp_work_dir(&format!("speech-smoke-{path}"))?;
10655 let audio_path = work_dir.join(format!("{path}.wav"));
10656 let synth = self
10657 .synthesize(SynthesizeRequest {
10658 text: text.to_string(),
10659 model: Some(tts.name.clone()),
10660 voice: default_speech_voice(tts),
10661 language: Some("en".to_string()),
10662 output_path: Some(audio_path.display().to_string()),
10663 ..SynthesizeRequest::default()
10664 })
10665 .await?;
10666 let transcript = self
10667 .transcribe(TranscribeRequest {
10668 audio_path: synth.audio_path.clone(),
10669 model: Some(stt.name.clone()),
10670 language: Some("en".to_string()),
10671 prompt: None,
10672 timestamps: false,
10673 })
10674 .await?;
10675
10676 Ok(SpeechSmokePathReport {
10677 path: path.to_string(),
10678 tts_model: synth.model_used.unwrap_or_else(|| tts.name.clone()),
10679 stt_model: transcript.model_used.unwrap_or_else(|| stt.name.clone()),
10680 audio_path: PathBuf::from(synth.audio_path),
10681 transcript: transcript.text,
10682 })
10683 }
10684
10685 async fn ensure_speech_runtime(&self) -> Result<SpeechRuntime, InferenceError> {
10686 let mut guard = self.speech_runtime.lock().await;
10687 if let Some(runtime) = guard.as_ref() {
10688 if runtime.is_ready() {
10689 return Ok(runtime.clone());
10690 }
10691 }
10692
10693 let runtime =
10694 SpeechRuntime::new(speech_runtime_root_from_models_dir(&self.config.models_dir));
10695 if !runtime.is_ready() {
10696 bootstrap_speech_runtime(&runtime).await?;
10697 }
10698 if !runtime.is_ready() {
10699 return Err(InferenceError::InferenceFailed(format!(
10700 "managed speech runtime is not ready at {}",
10701 runtime.root.display()
10702 )));
10703 }
10704
10705 *guard = Some(runtime.clone());
10706 Ok(runtime)
10707 }
10708
10709 async fn transcribe_whisper(
10718 &self,
10719 schema: &ModelSchema,
10720 model: &str,
10721 req: &TranscribeRequest,
10722 reservation: Option<&mut resource_policy::LocalLoadReservation>,
10723 ) -> Result<TranscribeResult, InferenceError> {
10724 let model = model.to_string();
10725 let model_path = car_whisper::ensure_model(&model)
10726 .map_err(|e| InferenceError::InferenceFailed(format!("whisper download: {e}")))?;
10727 let measured_bytes = backend_cache::estimate_model_size(&model_path);
10728 let reservation = reservation.ok_or_else(|| {
10729 InferenceError::InferenceFailed("local Whisper path missing admission".into())
10730 })?;
10731 reservation
10732 .reconcile_measured_weights(measured_bytes)
10733 .map_err(InferenceError::from)?;
10734 let detached_lease = reservation.detached_lease();
10735 let language = req.language.clone().unwrap_or_else(|| "auto".to_string());
10737 let audio_path = std::path::PathBuf::from(&req.audio_path);
10738 let name = schema.name.clone();
10739 let req_language = req.language.clone();
10740 let text =
10741 run_admitted_blocking(detached_lease, move || -> Result<String, InferenceError> {
10742 let stt = car_whisper::WhisperStt::load_from_path(&model_path, &language)
10744 .map_err(|e| InferenceError::InferenceFailed(format!("whisper load: {e}")))?;
10745 stt.transcribe_file(&audio_path).map_err(|e| {
10746 InferenceError::InferenceFailed(format!("whisper transcribe: {e}"))
10747 })
10748 })
10749 .await
10750 .map_err(|e| InferenceError::InferenceFailed(format!("whisper join: {e}")))??;
10751 Ok(TranscribeResult::text_only(text, Some(name), req_language))
10752 }
10753
10754 async fn transcribe_local_mlx(
10755 &self,
10756 schema: &ModelSchema,
10757 req: &TranscribeRequest,
10758 reservation: Option<&mut resource_policy::LocalLoadReservation>,
10759 ) -> Result<TranscribeResult, InferenceError> {
10760 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
10761 let reservation = reservation.ok_or_else(|| {
10762 InferenceError::InferenceFailed("local STT path missing admission".into())
10763 })?;
10764 reservation
10765 .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
10766 .map_err(InferenceError::from)?;
10767 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
10769 {
10770 let parakeet = match backend::mlx_parakeet::ParakeetBackend::load(&model_dir) {
10775 Ok(p) => p,
10776 Err(native_err) => {
10777 tracing::info!(
10778 model = %schema.name,
10779 error = %native_err,
10780 "native MLX STT backend can't load this model; \
10781 falling back to the managed mlx-audio runtime"
10782 );
10783 return self
10784 .transcribe_via_speech_runtime(schema, req, reservation.detached_lease())
10785 .await
10786 .map_err(|runtime_err| {
10787 InferenceError::InferenceFailed(format!(
10788 "native MLX backend failed ({native_err}); \
10789 mlx-audio runtime fallback also failed ({runtime_err}). \
10790 Install the runtime with `car speech install`."
10791 ))
10792 });
10793 }
10794 };
10795 let (text, words) = if req.timestamps {
10797 parakeet
10798 .transcribe_detailed(Path::new(&req.audio_path))
10799 .map_err(|e| InferenceError::InferenceFailed(format!("native STT: {e}")))?
10800 } else {
10801 let t = parakeet
10802 .transcribe(Path::new(&req.audio_path))
10803 .map_err(|e| InferenceError::InferenceFailed(format!("native STT: {e}")))?;
10804 (t, Vec::new())
10805 };
10806 Ok(TranscribeResult {
10807 text,
10808 model_used: Some(schema.name.clone()),
10809 language: req.language.clone(),
10810 words,
10811 routing_explanation: None,
10812 })
10813 }
10814
10815 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
10817 {
10818 self.transcribe_via_speech_runtime(schema, req, reservation.detached_lease())
10819 .await
10820 }
10821 }
10822
10823 async fn transcribe_via_speech_runtime(
10830 &self,
10831 schema: &ModelSchema,
10832 req: &TranscribeRequest,
10833 detached_lease: resource_policy::DetachedLocalLease,
10834 ) -> Result<TranscribeResult, InferenceError> {
10835 {
10836 let runtime = self.ensure_speech_runtime().await?;
10837 let hf_repo = match &schema.source {
10838 ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
10839 _ => {
10840 return Err(InferenceError::InferenceFailed(format!(
10841 "speech runtime needs an MLX model repo; {} is not one",
10842 schema.id
10843 )))
10844 }
10845 };
10846 let output_dir = temp_work_dir("stt")?;
10847 let output_prefix = output_dir.join("transcript");
10848 let mut args = vec![
10849 "--model".to_string(),
10850 hf_repo,
10851 "--audio".to_string(),
10852 req.audio_path.clone(),
10853 "--output-path".to_string(),
10854 output_prefix.display().to_string(),
10855 "--format".to_string(),
10856 "json".to_string(),
10857 ];
10858 if let Some(language) = &req.language {
10859 args.push("--language".to_string());
10860 args.push(normalize_lang_code(language));
10861 }
10862 if let Some(prompt) = &req.prompt {
10863 args.push("--context".to_string());
10864 args.push(prompt.clone());
10865 }
10866 if req.timestamps {
10867 args.push("--verbose".to_string());
10868 }
10869
10870 let output =
10871 run_mlx_audio_command(&runtime, "stt.generate", &args, detached_lease).await?;
10872 let text = read_transcription_result(&output_prefix)?
10873 .or_else(|| extract_text_from_payload(&output.stdout))
10874 .ok_or_else(|| {
10875 InferenceError::InferenceFailed(format!(
10876 "mlx-audio transcription returned no text: {}",
10877 output.stderr
10878 ))
10879 })?;
10880
10881 Ok(TranscribeResult {
10882 text,
10883 model_used: Some(schema.name.clone()),
10884 language: req.language.clone(),
10885 words: Vec::new(),
10886 routing_explanation: None,
10887 })
10888 }
10889 }
10890
10891 async fn synthesize_local_mlx(
10892 &self,
10893 schema: &ModelSchema,
10894 req: &SynthesizeRequest,
10895 reservation: Option<&mut resource_policy::LocalLoadReservation>,
10896 ) -> Result<SynthesizeResult, InferenceError> {
10897 let requested = req.requested_advanced_controls();
10902 let repo_supports_advanced = match &schema.source {
10903 ModelSource::Mlx { hf_repo, .. } => hf_repo.to_ascii_lowercase().contains("qwen3-tts"),
10904 _ => false,
10905 };
10906 if !requested.is_empty() && !repo_supports_advanced {
10907 if req.strict_capabilities {
10908 return Err(InferenceError::InferenceFailed(format!(
10909 "model {name} does not support Qwen3-TTS advanced controls {requested:?}; \
10910 route to a Qwen3-TTS model or set strict_capabilities = false to degrade",
10911 name = schema.name,
10912 )));
10913 }
10914 tracing::warn!(
10915 model = %schema.name,
10916 fields = ?requested,
10917 "Qwen3-TTS advanced controls set on non-Qwen3-TTS backend — ignored \
10918 (set strict_capabilities=true to error instead)"
10919 );
10920 }
10921
10922 let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
10923 let reservation = reservation.ok_or_else(|| {
10924 InferenceError::InferenceFailed("local TTS path missing admission".into())
10925 })?;
10926 reservation
10927 .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
10928 .map_err(InferenceError::from)?;
10929
10930 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
10932 {
10933 if repo_supports_advanced && !requested.is_empty() {
10939 if req.strict_capabilities {
10940 return Err(InferenceError::InferenceFailed(format!(
10941 "native MLX TTS backend does not yet implement Qwen3-TTS advanced \
10942 controls {requested:?}; run on non-Apple-Silicon to use the Python \
10943 mlx-audio fallback, or set strict_capabilities = false"
10944 )));
10945 }
10946 tracing::warn!(
10947 model = %schema.name,
10948 fields = ?requested,
10949 "Qwen3-TTS advanced controls are not yet implemented in the native MLX TTS \
10950 backend; synthesizing without cloning/voice-design"
10951 );
10952 }
10953 let size = backend_cache::estimate_model_size(&model_dir);
10954 let cache_key = reservation.model_id().to_string();
10955 let (handle, retention) = match Self::load_backend_healing(
10957 &cache_key,
10958 model_dir,
10959 &self.kokoro_cache,
10960 size,
10961 reservation,
10962 backend::mlx_kokoro::KokoroBackend::load,
10963 || self.unified_registry.redownload_local(&schema.id),
10964 )
10965 .await
10966 {
10967 Ok(handle) => handle,
10968 Err(native_err) => {
10976 tracing::info!(
10977 model = %schema.name,
10978 error = %native_err,
10979 "native MLX TTS backend can't load this model; \
10980 falling back to the managed mlx-audio runtime"
10981 );
10982 return self
10983 .synthesize_via_speech_runtime(schema, req, reservation.detached_lease())
10984 .await
10985 .map_err(|runtime_err| {
10986 InferenceError::InferenceFailed(format!(
10987 "native MLX backend failed ({native_err}); \
10988 mlx-audio runtime fallback also failed ({runtime_err}). \
10989 Install the runtime with `car speech install`."
10990 ))
10991 });
10992 }
10993 };
10994
10995 let output_path = req.output_path.clone().unwrap_or_else(|| {
10996 let dir = std::env::temp_dir().join("car_tts");
10997 let _ = std::fs::create_dir_all(&dir);
10998 dir.join("output.wav").display().to_string()
10999 });
11000 let voice = req.voice.as_deref().unwrap_or("af_heart").to_string();
11001 let text = req.text.clone();
11002 let detached_lease = (retention == backend_cache::BackendRetention::Transient)
11003 .then(|| reservation.detached_lease());
11004 let device_guard = Self::mlx_device_lock().lock_owned().await;
11010 let op = tokio::task::spawn_blocking(move || -> Result<PathBuf, InferenceError> {
11011 let _detached_lease = detached_lease;
11012 let _device_guard = device_guard;
11013 let mut guard = handle.lock().map_err(|_| {
11014 InferenceError::InferenceFailed("kokoro backend mutex poisoned".into())
11015 })?;
11016 guard
11017 .synthesize(&text, Some(&voice), Path::new(&output_path))
11018 .map_err(|e| InferenceError::InferenceFailed(format!("native TTS: {e}")))
11019 })
11020 .await
11021 .map_err(|e| InferenceError::InferenceFailed(format!("kokoro task join: {e}")))??;
11022
11023 let final_path =
11024 materialize_audio_output(&op, req.output_path.as_deref(), &req.format)?;
11025 Ok(SynthesizeResult {
11026 audio_path: final_path.display().to_string(),
11027 media_type: media_type_for_format(&req.format),
11028 model_used: Some(schema.name.clone()),
11029 voice_used: req.voice.clone(),
11030 routing_explanation: None,
11031 })
11032 }
11033
11034 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
11036 {
11037 self.synthesize_via_speech_runtime(schema, req, reservation.detached_lease())
11038 .await
11039 }
11040 }
11041
11042 async fn synthesize_via_speech_runtime(
11055 &self,
11056 schema: &ModelSchema,
11057 req: &SynthesizeRequest,
11058 detached_lease: resource_policy::DetachedLocalLease,
11059 ) -> Result<SynthesizeResult, InferenceError> {
11060 let runtime = self.ensure_speech_runtime().await?;
11061 let primary_hf_repo = match &schema.source {
11062 ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
11063 _ => {
11064 return Err(InferenceError::InferenceFailed(format!(
11065 "speech runtime needs an MLX model repo; {} is not one",
11066 schema.id
11067 )))
11068 }
11069 };
11070 let (produced, model_used) = match self
11071 .synthesize_local_mlx_repo(
11072 &runtime,
11073 &primary_hf_repo,
11074 schema.name.as_str(),
11075 req,
11076 detached_lease.clone(),
11077 )
11078 .await
11079 {
11080 Ok(result) => result,
11081 Err(primary_err)
11082 if primary_hf_repo == "mlx-community/Kokoro-82M-6bit"
11083 && kokoro_runtime_fallback_enabled() =>
11084 {
11085 let fallback_repo = "mlx-community/Kokoro-82M-bf16";
11086 let fallback_name = "Kokoro-82M-bf16";
11087 match self
11088 .synthesize_local_mlx_repo(
11089 &runtime,
11090 fallback_repo,
11091 fallback_name,
11092 req,
11093 detached_lease.clone(),
11094 )
11095 .await
11096 {
11097 Ok(result) => result,
11098 Err(fallback_err) => {
11099 return Err(InferenceError::InferenceFailed(format!(
11100 "{primary_err}; fallback {fallback_name} also failed: {fallback_err}"
11101 )));
11102 }
11103 }
11104 }
11105 Err(err) => return Err(err),
11106 };
11107 let final_path =
11108 materialize_audio_output(&produced, req.output_path.as_deref(), &req.format)?;
11109
11110 Ok(SynthesizeResult {
11111 audio_path: final_path.display().to_string(),
11112 media_type: media_type_for_format(&req.format),
11113 model_used: Some(model_used),
11114 voice_used: req.voice.clone(),
11115 routing_explanation: None,
11116 })
11117 }
11118
11119 async fn synthesize_local_mlx_repo(
11120 &self,
11121 runtime: &SpeechRuntime,
11122 hf_repo: &str,
11123 model_name: &str,
11124 req: &SynthesizeRequest,
11125 detached_lease: resource_policy::DetachedLocalLease,
11126 ) -> Result<(PathBuf, String), InferenceError> {
11127 let output_dir = temp_work_dir("tts")?;
11128 let mut args = vec![
11129 "--model".to_string(),
11130 hf_repo.to_string(),
11131 "--text".to_string(),
11132 req.text.clone(),
11133 "--output_path".to_string(),
11134 output_dir.display().to_string(),
11135 ];
11136 if let Some(voice) = &req.voice {
11137 args.push("--voice".to_string());
11138 args.push(voice.clone());
11139 }
11140 if let Some(speed) = req.speed {
11141 args.push("--speed".to_string());
11142 args.push(speed.to_string());
11143 }
11144 let repo_lower = hf_repo.to_ascii_lowercase();
11145 if repo_lower.contains("kokoro") {
11146 args.push("--lang_code".to_string());
11147 args.push(kokoro_lang_code(req.language.as_deref()).to_string());
11148 } else if let Some(language) = &req.language {
11149 args.push("--lang_code".to_string());
11150 args.push(normalize_lang_code(language));
11151 }
11152
11153 if repo_lower.contains("qwen3-tts") {
11159 if let Some(ref_audio) = &req.reference_audio_path {
11160 args.push("--ref_audio".to_string());
11161 args.push(ref_audio.clone());
11162 }
11163 if let Some(ref_text) = &req.reference_text {
11164 args.push("--ref_text".to_string());
11165 args.push(ref_text.clone());
11166 }
11167 if let Some(instruct) = &req.voice_instruction {
11168 args.push("--instruct".to_string());
11169 args.push(instruct.clone());
11170 }
11171 }
11172
11173 let output = if repo_lower.contains("kokoro") {
11174 let device = std::env::var("CAR_SPEECH_KOKORO_DEVICE")
11175 .or_else(|_| std::env::var("CAR_SPEECH_MLX_DEVICE"))
11176 .unwrap_or_else(|_| "cpu".to_string());
11177 let extra_env = vec![
11178 ("MLX_DEVICE".to_string(), device),
11180 ("PYTORCH_ENABLE_MPS_FALLBACK".to_string(), "1".to_string()),
11182 ];
11183 run_mlx_audio_command_with_env(
11184 runtime,
11185 "tts.generate",
11186 &args,
11187 &extra_env,
11188 detached_lease,
11189 )
11190 .await?
11191 } else {
11192 run_mlx_audio_command(runtime, "tts.generate", &args, detached_lease).await?
11193 };
11194 let produced = find_audio_file(&output_dir)?.ok_or_else(|| {
11195 let hint = if repo_lower.contains("kokoro") {
11196 ". Kokoro models may crash on GPU — try CAR_SPEECH_KOKORO_DEVICE=cpu or use the default Qwen3-TTS model"
11197 } else {
11198 ""
11199 };
11200 InferenceError::InferenceFailed(format!(
11201 "mlx-audio synthesis produced no audio file: {}{}",
11202 output.stderr, hint
11203 ))
11204 })?;
11205 Ok((produced, model_name.to_string()))
11206 }
11207
11208 async fn transcribe_elevenlabs(
11209 &self,
11210 schema: &ModelSchema,
11211 req: &TranscribeRequest,
11212 ) -> Result<TranscribeResult, InferenceError> {
11213 let (endpoint, api_key) = elevenlabs_auth(
11214 schema,
11215 crate::tasks::transcribe::resolve_provider_credential_for_request,
11216 )?;
11217 let file_name = Path::new(&req.audio_path)
11218 .file_name()
11219 .and_then(|f| f.to_str())
11220 .unwrap_or("audio.wav")
11221 .to_string();
11222 let audio_bytes = tokio::fs::read(&req.audio_path).await?;
11223 let file_part = Part::bytes(audio_bytes).file_name(file_name);
11224 let mut form = Form::new()
11225 .text("model_id", schema.name.clone())
11226 .part("file", file_part);
11227 if let Some(language) = &req.language {
11228 form = form.text("language_code", language.clone());
11229 }
11230
11231 let resp = self
11232 .remote_backend
11233 .client
11234 .post(format!(
11235 "{}/v1/speech-to-text",
11236 endpoint.trim_end_matches('/')
11237 ))
11238 .header("xi-api-key", api_key)
11239 .multipart(form)
11240 .send()
11241 .await
11242 .map_err(|e| {
11243 self.remote_backend
11244 .request_error("ElevenLabs STT request failed", &e)
11245 })?;
11246 let status = resp.status();
11247 let body = resp.text().await.map_err(|e| {
11248 InferenceError::InferenceFailed(format!("read ElevenLabs STT body: {e}"))
11249 })?;
11250 if !status.is_success() {
11251 return Err(InferenceError::InferenceFailed(format!(
11252 "ElevenLabs STT returned {status}: {body}"
11253 )));
11254 }
11255 let payload: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
11256 InferenceError::InferenceFailed(format!("parse ElevenLabs STT response: {e}"))
11257 })?;
11258 let text = payload
11259 .get("text")
11260 .and_then(|v| v.as_str())
11261 .map(str::to_string)
11262 .ok_or_else(|| {
11263 InferenceError::InferenceFailed("ElevenLabs STT response missing text".into())
11264 })?;
11265
11266 Ok(TranscribeResult {
11267 text,
11268 model_used: Some(schema.name.clone()),
11269 language: payload
11270 .get("language_code")
11271 .and_then(|v| v.as_str())
11272 .map(str::to_string),
11273 words: Vec::new(),
11274 routing_explanation: None,
11275 })
11276 }
11277
11278 async fn synthesize_elevenlabs(
11279 &self,
11280 schema: &ModelSchema,
11281 req: &SynthesizeRequest,
11282 ) -> Result<SynthesizeResult, InferenceError> {
11283 let requested = req.requested_advanced_controls();
11287 if !requested.is_empty() {
11288 if req.strict_capabilities {
11289 return Err(InferenceError::InferenceFailed(format!(
11290 "ElevenLabs backend does not support Qwen3-TTS advanced controls \
11291 {requested:?}; route to a Qwen3-TTS model or set strict_capabilities = false"
11292 )));
11293 }
11294 tracing::warn!(
11295 model = %schema.name,
11296 fields = ?requested,
11297 "Qwen3-TTS advanced controls ignored by ElevenLabs backend"
11298 );
11299 }
11300 let (endpoint, api_key) = elevenlabs_auth(
11301 schema,
11302 crate::tasks::synthesize::resolve_provider_credential_for_request,
11303 )?;
11304 let voice_id = req
11305 .voice
11306 .clone()
11307 .unwrap_or_else(|| "JBFqnCBsd6RMkjVDRZzb".to_string());
11308 let output_format = elevenlabs_output_format(&req.format);
11309 let url = format!(
11310 "{}/v1/text-to-speech/{}?output_format={}",
11311 endpoint.trim_end_matches('/'),
11312 voice_id,
11313 output_format
11314 );
11315
11316 let mut body = serde_json::json!({
11317 "text": req.text,
11318 "model_id": schema.name,
11319 });
11320 if let Some(language) = &req.language {
11321 body["language_code"] = serde_json::Value::String(language.clone());
11322 }
11323
11324 let resp = self
11325 .remote_backend
11326 .client
11327 .post(url)
11328 .header("xi-api-key", api_key)
11329 .header("Content-Type", "application/json")
11330 .json(&body)
11331 .send()
11332 .await
11333 .map_err(|e| {
11334 self.remote_backend
11335 .request_error("ElevenLabs TTS request failed", &e)
11336 })?;
11337 let status = resp.status();
11338 let audio = resp.bytes().await.map_err(|e| {
11339 InferenceError::InferenceFailed(format!("read ElevenLabs TTS body: {e}"))
11340 })?;
11341 if !status.is_success() {
11342 let err_body = String::from_utf8_lossy(&audio);
11343 return Err(InferenceError::InferenceFailed(format!(
11344 "ElevenLabs TTS returned {status}: {err_body}"
11345 )));
11346 }
11347
11348 let final_path = requested_or_temp_output(req.output_path.as_deref(), &req.format)?;
11349 ensure_parent_dir(&final_path)?;
11350 tokio::fs::write(&final_path, &audio).await?;
11351
11352 Ok(SynthesizeResult {
11353 audio_path: final_path.display().to_string(),
11354 media_type: media_type_for_format(&req.format),
11355 model_used: Some(schema.name.clone()),
11356 voice_used: Some(voice_id),
11357 routing_explanation: None,
11358 })
11359 }
11360}
11361
11362#[derive(Default)]
11363struct ProviderAccumulator {
11364 configured: bool,
11365 local_models: usize,
11366 remote_models: usize,
11367 available_models: usize,
11368 capabilities: std::collections::HashSet<ModelCapability>,
11369}
11370
11371struct CommandOutput {
11375 stdout: String,
11376 stderr: String,
11377}
11378
11379#[derive(Debug, Clone)]
11380struct SpeechRuntime {
11381 root: PathBuf,
11382 python: PathBuf,
11383 stt_program: PathBuf,
11384 tts_program: PathBuf,
11385}
11386
11387impl SpeechRuntime {
11388 fn new(root: PathBuf) -> Self {
11389 let python = managed_venv::interpreter(&root);
11390 let stt_program = managed_venv::venv_program(&root, "mlx_audio.stt.generate");
11391 let tts_program = managed_venv::venv_program(&root, "mlx_audio.tts.generate");
11392 Self {
11393 root,
11394 python,
11395 stt_program,
11396 tts_program,
11397 }
11398 }
11399
11400 fn is_ready(&self) -> bool {
11401 managed_venv::interpreter_healthy(&self.root)
11406 && self.stt_program.exists()
11407 && self.tts_program.exists()
11408 }
11409
11410 fn command_for(&self, subcommand: &str) -> Result<&Path, InferenceError> {
11411 match subcommand {
11412 "stt.generate" => Ok(&self.stt_program),
11413 "tts.generate" => Ok(&self.tts_program),
11414 _ => Err(InferenceError::InferenceFailed(format!(
11415 "unknown speech subcommand: {subcommand}"
11416 ))),
11417 }
11418 }
11419}
11420
11421async fn run_mlx_audio_command(
11422 runtime: &SpeechRuntime,
11423 subcommand: &str,
11424 args: &[String],
11425 detached_lease: resource_policy::DetachedLocalLease,
11426) -> Result<CommandOutput, InferenceError> {
11427 run_mlx_audio_command_with_env(runtime, subcommand, args, &[], detached_lease).await
11428}
11429
11430async fn run_admitted_blocking<F, T>(
11434 detached_lease: resource_policy::DetachedLocalLease,
11435 operation: F,
11436) -> Result<T, tokio::task::JoinError>
11437where
11438 F: FnOnce() -> T + Send + 'static,
11439 T: Send + 'static,
11440{
11441 tokio::task::spawn_blocking(move || {
11442 let _detached_lease = detached_lease;
11443 operation()
11444 })
11445 .await
11446}
11447
11448struct DetachedSpeechProcess {
11449 child: Option<tokio::process::Child>,
11450 lease: Option<resource_policy::DetachedLocalLease>,
11451}
11452
11453impl Drop for DetachedSpeechProcess {
11454 fn drop(&mut self) {
11455 let Some(mut child) = self.child.take() else {
11456 return;
11457 };
11458 let lease = self.lease.take();
11459 let _ = child.start_kill();
11460 if tokio::runtime::Handle::try_current().is_ok() {
11461 tokio::spawn(async move {
11462 let _ = child.wait().await;
11463 drop(lease);
11464 });
11465 } else {
11466 std::mem::forget((child, lease));
11469 }
11470 }
11471}
11472
11473async fn run_mlx_audio_command_with_env(
11474 runtime: &SpeechRuntime,
11475 subcommand: &str,
11476 args: &[String],
11477 envs: &[(String, String)],
11478 detached_lease: resource_policy::DetachedLocalLease,
11479) -> Result<CommandOutput, InferenceError> {
11480 let program = runtime.command_for(subcommand)?;
11481 let mut command = Command::new(program);
11482 command.args(args);
11483 for (key, value) in envs {
11484 command.env(key, value);
11485 }
11486 command
11487 .stdout(std::process::Stdio::piped())
11488 .stderr(std::process::Stdio::piped())
11489 .kill_on_drop(true);
11490 let child = command
11491 .spawn()
11492 .map_err(|err| InferenceError::InferenceFailed(format!("{}: {err}", program.display())))?;
11493 let mut owned = DetachedSpeechProcess {
11494 child: Some(child),
11495 lease: Some(detached_lease),
11496 };
11497 let mut stdout = owned
11498 .child
11499 .as_mut()
11500 .expect("speech child owned")
11501 .stdout
11502 .take()
11503 .ok_or_else(|| {
11504 InferenceError::InferenceFailed(format!("{} stdout unavailable", program.display()))
11505 })?;
11506 let mut stderr = owned
11507 .child
11508 .as_mut()
11509 .expect("speech child owned")
11510 .stderr
11511 .take()
11512 .ok_or_else(|| {
11513 InferenceError::InferenceFailed(format!("{} stderr unavailable", program.display()))
11514 })?;
11515 let stdout_reader = tokio::spawn(async move {
11516 let mut bytes = Vec::new();
11517 stdout.read_to_end(&mut bytes).await.map(|_| bytes)
11518 });
11519 let stderr_reader = tokio::spawn(async move {
11520 let mut bytes = Vec::new();
11521 stderr.read_to_end(&mut bytes).await.map(|_| bytes)
11522 });
11523 let status = owned
11524 .child
11525 .as_mut()
11526 .expect("speech child owned")
11527 .wait()
11528 .await
11529 .map_err(|err| InferenceError::InferenceFailed(format!("{}: {err}", program.display())))?;
11530 owned.child.take();
11531 owned.lease.take();
11532 let stdout = stdout_reader
11533 .await
11534 .map_err(|err| InferenceError::InferenceFailed(format!("speech stdout join: {err}")))?
11535 .map_err(|err| InferenceError::InferenceFailed(format!("speech stdout: {err}")))?;
11536 let stderr = stderr_reader
11537 .await
11538 .map_err(|err| InferenceError::InferenceFailed(format!("speech stderr join: {err}")))?
11539 .map_err(|err| InferenceError::InferenceFailed(format!("speech stderr: {err}")))?;
11540
11541 if status.success() {
11542 Ok(CommandOutput {
11543 stdout: String::from_utf8_lossy(&stdout).to_string(),
11544 stderr: String::from_utf8_lossy(&stderr).to_string(),
11545 })
11546 } else {
11547 Err(InferenceError::InferenceFailed(format!(
11548 "{} exited with {}: {}",
11549 program.display(),
11550 status,
11551 String::from_utf8_lossy(&stderr)
11552 )))
11553 }
11554}
11555
11556async fn bootstrap_speech_runtime(runtime: &SpeechRuntime) -> Result<(), InferenceError> {
11557 let python = select_speech_python()?;
11558
11559 let outcome = managed_venv::ensure_venv(&runtime.root, &python)
11565 .await
11566 .map_err(|err| InferenceError::InferenceFailed(err.to_string()))?;
11567 if outcome == managed_venv::VenvOutcome::Recreated {
11568 tracing::warn!(
11569 root = %runtime.root.display(),
11570 "managed speech runtime had an unusable interpreter; rebuilt it \
11571 (installed packages were discarded and are being reinstalled)"
11572 );
11573 }
11574
11575 run_command(
11576 "uv",
11577 &[
11578 "pip".to_string(),
11579 "install".to_string(),
11580 "--python".to_string(),
11581 runtime.python.display().to_string(),
11582 speech_runtime_mlx_audio_spec(),
11583 "misaki[en]".to_string(),
11584 speech_runtime_spacy_model_spec(),
11585 ],
11586 )
11587 .await?;
11588
11589 Ok(())
11590}
11591
11592async fn run_command(program: &str, args: &[String]) -> Result<(), InferenceError> {
11593 let output = Command::new(program)
11594 .args(args)
11595 .output()
11596 .await
11597 .map_err(|err| InferenceError::InferenceFailed(format!("{program}: {err}")))?;
11598
11599 if output.status.success() {
11600 Ok(())
11601 } else {
11602 Err(InferenceError::InferenceFailed(format!(
11603 "{} exited with {}: {}",
11604 program,
11605 output.status,
11606 String::from_utf8_lossy(&output.stderr)
11607 )))
11608 }
11609}
11610
11611fn select_speech_python() -> Result<String, InferenceError> {
11612 if let Ok(path) = std::env::var("CAR_SPEECH_PYTHON") {
11613 if !path.trim().is_empty() {
11614 return Ok(path);
11615 }
11616 }
11617
11618 for candidate in ["python3.13", "python3.12", "python3.11"] {
11619 if command_in_path(candidate) {
11620 return Ok(candidate.to_string());
11621 }
11622 }
11623
11624 Ok(SPEECH_RUNTIME_FALLBACK_PYTHON.to_string())
11634}
11635
11636const SPEECH_RUNTIME_FALLBACK_PYTHON: &str = "3.12";
11642
11643fn detect_speech_python() -> Option<String> {
11644 if let Ok(path) = std::env::var("CAR_SPEECH_PYTHON") {
11645 if !path.trim().is_empty() {
11646 return Some(path);
11647 }
11648 }
11649
11650 ["python3.13", "python3.12", "python3.11"]
11651 .into_iter()
11652 .find(|candidate| command_in_path(candidate))
11653 .map(str::to_string)
11654}
11655
11656fn speech_runtime_root_from_models_dir(_models_dir: &Path) -> PathBuf {
11657 if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
11658 if !path.trim().is_empty() {
11659 return PathBuf::from(path);
11660 }
11661 }
11662
11663 std::env::var_os("HOME")
11664 .or_else(|| std::env::var_os("USERPROFILE"))
11665 .map(PathBuf::from)
11666 .unwrap_or_else(|| PathBuf::from("."))
11667 .join(".car")
11668 .join("speech-runtime")
11669}
11670
11671fn command_in_path(name: &str) -> bool {
11672 std::env::var_os("PATH")
11673 .map(|paths| {
11674 std::env::split_paths(&paths).any(|dir| {
11675 let path = dir.join(name);
11676 path.exists() && path.is_file()
11677 })
11678 })
11679 .unwrap_or(false)
11680}
11681
11682fn speech_model_cached(schema: &ModelSchema) -> bool {
11683 match &schema.source {
11684 ModelSource::Mlx { hf_repo, .. } => huggingface_repo_has_snapshot(hf_repo),
11685 ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
11686 ModelSource::WindowsSpeech {} => cfg!(target_os = "windows"),
11688 ModelSource::Proprietary { .. } => false,
11691 _ => false,
11692 }
11693}
11694
11695fn model_source_configured(schema: &ModelSchema) -> bool {
11696 match &schema.source {
11697 ModelSource::RemoteApi {
11698 protocol: ApiProtocol::OpenRouter,
11699 ..
11700 } => crate::openrouter::credential_source().is_some(),
11701 ModelSource::RemoteApi {
11702 api_key_env,
11703 api_key_envs,
11704 ..
11705 } => {
11706 std::env::var(api_key_env).is_ok_and(|value| !value.trim().is_empty())
11707 || api_key_envs.iter().any(|env_var| {
11708 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11709 })
11710 }
11711 ModelSource::Proprietary { auth, .. } => match auth {
11712 ProprietaryAuth::ApiKeyEnv { env_var } => {
11713 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11714 }
11715 ProprietaryAuth::BearerTokenEnv { env_var } => {
11716 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11717 }
11718 ProprietaryAuth::OAuth2Pkce { .. } => matches!(
11719 car_auth::credential_authority_hint().state,
11720 car_auth::CredentialAuthorityState::Configured
11721 ),
11722 },
11723 ModelSource::VllmMlx { .. } => {
11724 std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available
11725 }
11726 ModelSource::Ollama { .. } => schema.available,
11727 ModelSource::CodexCli { .. } => crate::backend::codex_cli::is_available(),
11728 ModelSource::Mlx { .. }
11729 | ModelSource::ManagedVllmMlx { .. }
11730 | ModelSource::Local { .. }
11731 | ModelSource::WhisperCpp { .. } => true,
11732 ModelSource::WindowsSpeech {} => schema.available,
11734 ModelSource::AppleFoundationModels { .. } => schema.available,
11735 ModelSource::Delegated { .. } => true,
11740 }
11741}
11742
11743fn all_model_capabilities() -> [ModelCapability; 13] {
11744 [
11745 ModelCapability::Generate,
11746 ModelCapability::Embed,
11747 ModelCapability::Classify,
11748 ModelCapability::Code,
11749 ModelCapability::Reasoning,
11750 ModelCapability::Summarize,
11751 ModelCapability::ToolUse,
11752 ModelCapability::MultiToolCall,
11753 ModelCapability::Vision,
11754 ModelCapability::SpeechToText,
11755 ModelCapability::TextToSpeech,
11756 ModelCapability::ImageGeneration,
11757 ModelCapability::VideoGeneration,
11758 ]
11759}
11760
11761fn sort_capabilities(mut capabilities: Vec<ModelCapability>) -> Vec<ModelCapability> {
11762 capabilities.sort_by_key(|capability| {
11763 all_model_capabilities()
11764 .iter()
11765 .position(|candidate| candidate == capability)
11766 .unwrap_or(usize::MAX)
11767 });
11768 capabilities
11769}
11770
11771fn speech_model_source_label(schema: &ModelSchema) -> String {
11772 match &schema.source {
11773 ModelSource::Mlx { hf_repo, .. } => format!("mlx:{hf_repo}"),
11774 ModelSource::ManagedVllmMlx { hf_repo, .. } => {
11775 format!("managed-vllm-mlx:{hf_repo}")
11776 }
11777 ModelSource::WhisperCpp { model } => format!("whisper:{model}"),
11778 ModelSource::WindowsSpeech {} => "windows-speech".to_string(),
11779 ModelSource::Proprietary {
11780 provider, endpoint, ..
11781 } => format!("proprietary:{provider}:{endpoint}"),
11782 ModelSource::RemoteApi { endpoint, .. } => format!("remote:{endpoint}"),
11783 ModelSource::CodexCli { model } => format!("codex-cli:{model}"),
11784 ModelSource::Local { hf_repo, .. } => format!("local:{hf_repo}"),
11785 ModelSource::VllmMlx {
11786 endpoint,
11787 model_name,
11788 } => format!("vllm-mlx:{endpoint}:{model_name}"),
11789 ModelSource::Ollama { model_tag, host } => format!("ollama:{host}:{model_tag}"),
11790 ModelSource::AppleFoundationModels { use_case } => {
11791 format!(
11792 "apple-foundation:{}",
11793 use_case.as_deref().unwrap_or("default")
11794 )
11795 }
11796 ModelSource::Delegated { hint } => {
11797 format!("delegated:{}", hint.as_deref().unwrap_or("(none)"))
11798 }
11799 }
11800}
11801
11802fn rerank_prompt(instruction: &str, query: &str, document: &str) -> String {
11810 const SYSTEM: &str = "Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".";
11811 format!(
11812 "<|im_start|>system\n{SYSTEM}<|im_end|>\n\
11813 <|im_start|>user\n<Instruct>: {instruction}\n<Query>: {query}\n<Document>: {document}<|im_end|>\n\
11814 <|im_start|>assistant\n<think>\n\n</think>\n\n"
11815 )
11816}
11817
11818fn parse_api_returned_status(err: &str) -> Option<u16> {
11842 let lower = err.to_ascii_lowercase();
11843 let idx = lower.find("api returned ")?;
11844 let digits: String = lower[idx + "api returned ".len()..]
11845 .chars()
11846 .take_while(|c| c.is_ascii_digit())
11847 .take(3)
11848 .collect();
11849 digits.parse().ok()
11850}
11851
11852pub const NO_BACKEND_RECOVERY_MARKER: &str = "no inference backend is available.";
11865
11866#[doc(hidden)]
11873pub fn no_backend_recovery_hint(underlying: &str) -> Option<String> {
11874 let is_no_backend = underlying.contains("no credential")
11875 || underlying.contains("model not found")
11876 || underlying.contains("no models available")
11877 || underlying.contains("no inference runner");
11878 if !is_no_backend {
11879 return None;
11880 }
11881 Some(format!(
11887 "{NO_BACKEND_RECOVERY_MARKER} To use Parslee's hosted models, run:\n \
11888 car auth login\n\
11889 Or, for a model you name yourself with --model, install a local \
11890 tool-capable one (works on Windows/Linux/macOS):\n \
11891 car models pull qwen/qwen3-4b:q4_k_m\n\
11892 (underlying error: {underlying})"
11893 ))
11894}
11895
11896pub fn is_auth_rejection_message(underlying: &str) -> bool {
11914 let l = underlying.to_ascii_lowercase();
11915 l.contains("org lookup failed")
11916 || l.contains("authentication required")
11917 || (l.contains("401") && l.contains("unauthorized"))
11918 || (l.contains("403") && l.contains("forbidden"))
11919 || l.contains("invalid_grant")
11920 || l.contains("token expired")
11921}
11922
11923pub fn is_auth_failure_message(message: &str) -> bool {
11930 let lower = message.to_ascii_lowercase();
11931 AUTH_FAILURE_MESSAGE_MARKERS
11932 .iter()
11933 .any(|marker| lower.contains(marker))
11934 || is_auth_rejection_message(message)
11935}
11936
11937fn record_auth_dead_lane(slot: &mut Option<String>, candidate: &str, error: &str) {
11946 if slot.is_none() && is_auth_rejection_message(error) {
11947 *slot = Some(candidate.to_string());
11948 }
11949}
11950
11951fn is_local_last_resort(appended_id: Option<&str>, candidate_id: &str) -> bool {
11956 appended_id == Some(candidate_id)
11957}
11958
11959fn report_local_last_resort_served(
11963 appended_id: Option<&str>,
11964 candidate_id: &str,
11965 resolved_id: &str,
11966) -> bool {
11967 let served = is_local_last_resort(appended_id, candidate_id);
11968 if served {
11969 tracing::warn!(
11970 local_model = %resolved_id,
11971 "on-device last-resort model served inference turn"
11972 );
11973 }
11974 served
11975}
11976
11977fn auth_expired_recovery_hint(underlying: &str) -> Option<String> {
11987 if !is_auth_rejection_message(underlying) {
11988 return None;
11989 }
11990 Some(format!(
11991 "your Parslee session has expired or was rejected — re-authenticate:\n \
11992 car auth login\n\
11993 Or, for a model you name yourself with --model, install a local \
11994 tool-capable one:\n \
11995 car models pull qwen/qwen3-4b:q4_k_m\n\
11996 (underlying error: {underlying})"
11997 ))
11998}
11999
12000fn score_from_rerank_output(text: &str, model_name: &str) -> f32 {
12001 let normalized: String = text
12006 .to_ascii_lowercase()
12007 .chars()
12008 .map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' })
12009 .collect();
12010 for tok in normalized.split_ascii_whitespace().take(5) {
12011 match tok {
12012 "yes" => return 1.0,
12013 "no" => return 0.0,
12014 _ => continue,
12015 }
12016 }
12017 tracing::warn!(
12018 model = %model_name,
12019 output = %text,
12020 "rerank: first tokens contain neither `yes` nor `no`; returning neutral 0.5"
12021 );
12022 0.5
12023}
12024
12025fn default_speech_voice(schema: &ModelSchema) -> Option<String> {
12026 if schema.provider == "elevenlabs" {
12027 Some("JBFqnCBsd6RMkjVDRZzb".to_string())
12028 } else if schema.name == "Kokoro-82M-6bit" || schema.name == "Kokoro-82M-bf16" {
12029 Some("af_heart".to_string())
12030 } else if schema.name == "Qwen3-TTS-12Hz-1.7B-Base-5bit" {
12031 Some("Chelsie".to_string())
12032 } else {
12033 None
12034 }
12035}
12036
12037#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
12039 find_latest_huggingface_snapshot(repo_id).is_some()
12040}
12041
12042fn huggingface_repo_dir(repo_id: &str) -> PathBuf {
12043 let cache_root = std::env::var("HF_HOME")
12044 .map(PathBuf::from)
12045 .unwrap_or_else(|_| {
12046 std::env::var_os("HOME")
12047 .or_else(|| std::env::var_os("USERPROFILE"))
12048 .map(PathBuf::from)
12049 .unwrap_or_else(|| PathBuf::from("."))
12050 .join(".cache")
12051 .join("huggingface")
12052 })
12053 .join("hub");
12054 cache_root.join(format!("models--{}", repo_id.replace('/', "--")))
12055}
12056
12057fn find_latest_huggingface_snapshot(repo_id: &str) -> Option<PathBuf> {
12058 let snapshots = huggingface_repo_dir(repo_id).join("snapshots");
12059 std::fs::read_dir(snapshots)
12060 .ok()?
12061 .filter_map(Result::ok)
12062 .map(|entry| entry.path())
12063 .find(|path| path.is_dir() && snapshot_looks_ready(path))
12064}
12065
12066fn snapshot_looks_ready(path: &Path) -> bool {
12067 if path.join("config.json").exists() || path.join("model_index.json").exists() {
12068 return true;
12069 }
12070 snapshot_contains_ext(path, "safetensors")
12071}
12072
12073fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
12074 let Ok(entries) = std::fs::read_dir(root) else {
12075 return false;
12076 };
12077 entries.filter_map(Result::ok).any(|entry| {
12078 let path = entry.path();
12079 if path.is_dir() {
12080 snapshot_contains_ext(&path, ext)
12081 } else {
12082 let ext_matches = path
12083 .extension()
12084 .and_then(|value| value.to_str())
12085 .map(|value| value.eq_ignore_ascii_case(ext))
12086 .unwrap_or(false);
12087 ext_matches && crate::download::cache_file_usable(&path)
12091 }
12092 })
12093}
12094
12095#[allow(dead_code)] fn count_files_recursive(root: &Path) -> usize {
12097 let Ok(entries) = std::fs::read_dir(root) else {
12098 return 0;
12099 };
12100 entries
12101 .filter_map(Result::ok)
12102 .map(|entry| entry.path())
12103 .map(|path| {
12104 if path.is_dir() {
12105 count_files_recursive(&path)
12106 } else if path.is_file() {
12107 1
12108 } else {
12109 0
12110 }
12111 })
12112 .sum()
12113}
12114
12115async fn download_hf_repo_snapshot(repo_id: &str) -> Result<(PathBuf, usize), InferenceError> {
12116 let api = hf_hub::api::tokio::ApiBuilder::from_env()
12117 .with_progress(false)
12118 .build()
12119 .map_err(|e| InferenceError::DownloadFailed(format!("init hf api: {e}")))?;
12120 let repo = api.model(repo_id.to_string());
12121 let info = repo
12122 .info()
12123 .await
12124 .map_err(|e| InferenceError::DownloadFailed(format!("{repo_id}: {e}")))?;
12125
12126 let snapshot_path = huggingface_repo_dir(repo_id)
12127 .join("snapshots")
12128 .join(&info.sha);
12129 let mut downloaded = 0usize;
12130 for sibling in &info.siblings {
12131 let local_path = snapshot_path.join(&sibling.rfilename);
12132 if crate::download::cache_file_usable(&local_path) {
12140 downloaded += 1;
12141 continue;
12142 }
12143 let _ = std::fs::remove_file(&local_path);
12148 repo.download(&sibling.rfilename).await.map_err(|e| {
12149 InferenceError::DownloadFailed(format!("{repo_id}/{}: {e}", sibling.rfilename))
12150 })?;
12151 downloaded += 1;
12152 }
12153
12154 Ok((snapshot_path, downloaded))
12155}
12156
12157fn temp_work_dir(prefix: &str) -> Result<PathBuf, InferenceError> {
12158 let unique = SystemTime::now()
12159 .duration_since(UNIX_EPOCH)
12160 .map_err(|e| InferenceError::InferenceFailed(format!("clock error: {e}")))?
12161 .as_nanos();
12162 let dir = std::env::temp_dir().join(format!("car-inference-{prefix}-{unique}"));
12163 std::fs::create_dir_all(&dir)?;
12164 Ok(dir)
12165}
12166
12167fn ensure_parent_dir(path: &Path) -> Result<(), InferenceError> {
12168 if let Some(parent) = path.parent() {
12169 std::fs::create_dir_all(parent)?;
12170 }
12171 Ok(())
12172}
12173
12174fn requested_or_temp_output(
12175 output_path: Option<&str>,
12176 format: &str,
12177) -> Result<PathBuf, InferenceError> {
12178 if let Some(path) = output_path {
12179 return Ok(PathBuf::from(path));
12180 }
12181 let dir = temp_work_dir("audio-out")?;
12182 Ok(dir.join(format!("speech.{format}")))
12183}
12184
12185#[allow(dead_code)] fn requested_or_temp_media_output(
12187 output_path: Option<&str>,
12188 format: &str,
12189 stem: &str,
12190) -> Result<PathBuf, InferenceError> {
12191 if let Some(path) = output_path {
12192 return Ok(PathBuf::from(path));
12193 }
12194 let dir = temp_work_dir(&format!("{stem}-out"))?;
12195 Ok(dir.join(format!("{stem}.{format}")))
12196}
12197
12198fn materialize_audio_output(
12199 produced: &Path,
12200 requested: Option<&str>,
12201 format: &str,
12202) -> Result<PathBuf, InferenceError> {
12203 if let Some(path) = requested {
12204 let dest = PathBuf::from(path);
12205 ensure_parent_dir(&dest)?;
12206 std::fs::copy(produced, &dest)?;
12207 Ok(dest)
12208 } else {
12209 let dest = requested_or_temp_output(None, format)?;
12210 ensure_parent_dir(&dest)?;
12211 std::fs::copy(produced, &dest)?;
12212 Ok(dest)
12213 }
12214}
12215
12216#[cfg(target_os = "windows")]
12222fn winrt_synthesize_wav(text: &str, voice: &str, rate: f64) -> Result<Vec<u8>, InferenceError> {
12223 use windows::core::HSTRING;
12224 use windows::Media::SpeechSynthesis::SpeechSynthesizer;
12225 use windows::Storage::Streams::DataReader;
12226
12227 let err = |m: String| InferenceError::InferenceFailed(m);
12228 let synth =
12229 SpeechSynthesizer::new().map_err(|e| err(format!("SpeechSynthesizer::new: {e}")))?;
12230 if let Ok(opts) = synth.Options() {
12231 let _ = opts.SetSpeakingRate(rate.clamp(0.5, 6.0));
12232 }
12233 if !voice.is_empty() {
12234 if let Ok(all) = SpeechSynthesizer::AllVoices() {
12235 let want = voice.to_lowercase();
12236 let count = all.Size().unwrap_or(0);
12237 for i in 0..count {
12238 if let Ok(info) = all.GetAt(i) {
12239 if let Ok(name) = info.DisplayName() {
12240 if name.to_string_lossy().to_lowercase().contains(&want) {
12241 let _ = synth.SetVoice(&info);
12242 break;
12243 }
12244 }
12245 }
12246 }
12247 }
12248 }
12249 let stream = synth
12250 .SynthesizeTextToStreamAsync(&HSTRING::from(text))
12251 .map_err(|e| err(format!("SynthesizeTextToStreamAsync: {e}")))?
12252 .get()
12253 .map_err(|e| err(format!("synthesize await: {e}")))?;
12254 let size = stream
12255 .Size()
12256 .map_err(|e| err(format!("stream size: {e}")))?;
12257 let input = stream
12258 .GetInputStreamAt(0)
12259 .map_err(|e| err(format!("input stream: {e}")))?;
12260 let reader =
12261 DataReader::CreateDataReader(&input).map_err(|e| err(format!("data reader: {e}")))?;
12262 reader
12263 .LoadAsync(size as u32)
12264 .map_err(|e| err(format!("load async: {e}")))?
12265 .get()
12266 .map_err(|e| err(format!("load await: {e}")))?;
12267 let mut buf = vec![0u8; size as usize];
12268 reader
12269 .ReadBytes(&mut buf)
12270 .map_err(|e| err(format!("read bytes: {e}")))?;
12271 Ok(buf)
12272}
12273
12274#[allow(dead_code)] fn read_transcription_result(output_prefix: &Path) -> Result<Option<String>, InferenceError> {
12276 let candidates = [
12277 output_prefix.with_extension("json"),
12278 output_prefix.to_path_buf(),
12279 ];
12280
12281 for path in candidates {
12282 if path.exists() {
12283 let contents = std::fs::read_to_string(path)?;
12284 if let Some(text) = extract_text_from_payload(&contents) {
12285 return Ok(Some(text));
12286 }
12287 }
12288 }
12289
12290 Ok(None)
12291}
12292
12293#[allow(dead_code)] fn extract_text_from_payload(payload: &str) -> Option<String> {
12295 let value: serde_json::Value = serde_json::from_str(payload).ok()?;
12296 if let Some(text) = value.get("text").and_then(|v| v.as_str()) {
12297 return Some(text.to_string());
12298 }
12299 if let Some(transcripts) = value.get("transcripts").and_then(|v| v.as_array()) {
12300 let joined = transcripts
12301 .iter()
12302 .filter_map(|item| item.get("text").and_then(|v| v.as_str()))
12303 .collect::<Vec<_>>()
12304 .join("\n");
12305 if !joined.is_empty() {
12306 return Some(joined);
12307 }
12308 }
12309 if let Some(items) = value.as_array() {
12310 let joined = items
12311 .iter()
12312 .filter_map(|item| {
12313 item.get("text")
12314 .or_else(|| item.get("Content"))
12315 .and_then(|v| v.as_str())
12316 })
12317 .collect::<Vec<_>>()
12318 .join(" ");
12319 if !joined.is_empty() {
12320 return Some(joined);
12321 }
12322 }
12323 None
12324}
12325
12326#[allow(dead_code)] fn find_audio_file(output_dir: &Path) -> Result<Option<PathBuf>, InferenceError> {
12328 let mut audio_files = Vec::new();
12329 collect_audio_files(output_dir, &mut audio_files)?;
12330 audio_files.sort();
12331 Ok(audio_files.into_iter().next())
12332}
12333
12334#[allow(dead_code)] fn collect_audio_files(dir: &Path, audio_files: &mut Vec<PathBuf>) -> Result<(), InferenceError> {
12336 for entry in std::fs::read_dir(dir)? {
12337 let path = entry?.path();
12338 if path.is_dir() {
12339 collect_audio_files(&path, audio_files)?;
12340 } else if matches!(
12341 path.extension().and_then(|ext| ext.to_str()),
12342 Some("wav" | "mp3" | "flac" | "pcm" | "m4a")
12343 ) {
12344 audio_files.push(path);
12345 }
12346 }
12347 Ok(())
12348}
12349
12350fn media_type_for_format(format: &str) -> String {
12351 match format.to_ascii_lowercase().as_str() {
12352 "mp3" => "audio/mpeg".to_string(),
12353 "flac" => "audio/flac".to_string(),
12354 "pcm" => "audio/L16".to_string(),
12355 "m4a" => "audio/mp4".to_string(),
12356 _ => "audio/wav".to_string(),
12357 }
12358}
12359
12360fn kokoro_lang_code(language: Option<&str>) -> &'static str {
12361 match language.unwrap_or("en").to_ascii_lowercase().as_str() {
12362 "en-gb" | "british" | "british english" => "b",
12363 "ja" | "japanese" => "j",
12364 "zh" | "zh-cn" | "mandarin" | "chinese" => "z",
12365 "es" | "spanish" => "e",
12366 "fr" | "french" => "f",
12367 _ => "a",
12368 }
12369}
12370
12371#[allow(dead_code)] fn normalize_lang_code(language: &str) -> String {
12373 match language.to_ascii_lowercase().as_str() {
12374 "english" | "en-us" | "en_us" => "en".to_string(),
12375 "spanish" => "es".to_string(),
12376 "french" => "fr".to_string(),
12377 "japanese" => "ja".to_string(),
12378 "chinese" | "mandarin" => "zh".to_string(),
12379 other => match other {
12380 "en" | "es" | "fr" | "ja" | "zh" => other.to_string(),
12381 _ => "en".to_string(),
12382 },
12383 }
12384}
12385
12386fn elevenlabs_auth(
12387 schema: &ModelSchema,
12388 resolve_credential: fn(&str) -> Option<String>,
12389) -> Result<(String, String), InferenceError> {
12390 match &schema.source {
12391 ModelSource::Proprietary {
12392 endpoint,
12393 auth: schema::ProprietaryAuth::ApiKeyEnv { env_var },
12394 ..
12395 } => {
12396 let key = resolve_credential(env_var).ok_or_else(|| {
12397 InferenceError::InferenceFailed(format!(
12398 "missing API key {env_var}; set the environment variable or \
12399 store it with `car secrets put {env_var}`"
12400 ))
12401 })?;
12402 Ok((endpoint.clone(), key))
12403 }
12404 _ => Err(InferenceError::InferenceFailed(format!(
12405 "model {} is not an ElevenLabs proprietary model",
12406 schema.id
12407 ))),
12408 }
12409}
12410
12411fn elevenlabs_output_format(format: &str) -> &'static str {
12412 match format.to_ascii_lowercase().as_str() {
12413 "mp3" => "mp3_44100_128",
12414 "pcm" => "pcm_16000",
12415 _ => "wav_44100",
12416 }
12417}
12418
12419fn benchmark_priors_paths(state_models_dir: &Path) -> Vec<PathBuf> {
12424 let mut paths = Vec::new();
12425
12426 let direct = state_models_dir.join("benchmark_priors.json");
12427 if !paths.contains(&direct) {
12428 paths.push(direct);
12429 }
12430
12431 if let Some(parent) = state_models_dir.parent() {
12432 let parent_path = parent.join("benchmark_priors.json");
12433 if !paths.contains(&parent_path) {
12434 paths.push(parent_path);
12435 }
12436 }
12437
12438 if let Some(path) = std::env::var_os("CAR_BENCHMARK_PRIORS_PATH") {
12439 let path = PathBuf::from(path);
12440 if !paths.contains(&path) {
12441 paths.push(path);
12442 }
12443 }
12444
12445 paths
12446}
12447
12448fn load_benchmark_prior_health(
12449 state_models_dir: &Path,
12450 schemas: &[ModelSchema],
12451) -> Vec<ModelBenchmarkPriorHealth> {
12452 let mut priors = std::collections::BTreeMap::new();
12453 for path in benchmark_priors_paths(state_models_dir) {
12454 let Ok(loaded) = routing_ext::load_benchmark_priors(&path) else {
12455 continue;
12456 };
12457 for (model_id, prior) in loaded {
12458 let model_name = schemas
12459 .iter()
12460 .find(|schema| schema.id == model_id)
12461 .map(|schema| schema.name.clone());
12462 priors.insert(
12463 model_id.clone(),
12464 ModelBenchmarkPriorHealth {
12465 model_id,
12466 model_name,
12467 overall_score: prior.overall_score,
12468 overall_latency_ms: prior.overall_latency_ms,
12469 task_scores: prior.task_scores,
12470 task_latency_ms: prior.task_latency_ms,
12471 source_path: path.clone(),
12472 },
12473 );
12474 }
12475 }
12476
12477 priors.into_values().collect()
12478}
12479
12480fn kokoro_runtime_fallback_enabled() -> bool {
12481 std::env::var("CAR_SPEECH_KOKORO_FALLBACK")
12482 .ok()
12483 .map(|value| {
12484 !matches!(
12485 value.trim().to_ascii_lowercase().as_str(),
12486 "0" | "false" | "off"
12487 )
12488 })
12489 .unwrap_or(true)
12490}
12491
12492fn speech_runtime_mlx_audio_spec() -> String {
12493 std::env::var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC")
12494 .ok()
12495 .filter(|value| !value.trim().is_empty())
12496 .unwrap_or_else(|| "mlx-audio==0.4.2".to_string())
12497}
12498
12499fn speech_runtime_spacy_model_spec() -> String {
12500 std::env::var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC")
12501 .ok()
12502 .filter(|value| !value.trim().is_empty())
12503 .unwrap_or_else(|| {
12504 "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl".to_string()
12505 })
12506}
12507
12508#[cfg(test)]
12509pub(crate) fn run_in_isolated_test_process(test_name: &str, sentinel: &str) -> bool {
12510 static CHILD_PROCESS_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
12511
12512 if std::env::var_os(sentinel).is_some() {
12513 return true;
12514 }
12515
12516 let _child_process = CHILD_PROCESS_MUTEX
12517 .lock()
12518 .unwrap_or_else(std::sync::PoisonError::into_inner);
12519 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
12520 command
12521 .arg("--exact")
12522 .arg(test_name)
12523 .arg("--nocapture")
12524 .arg("--test-threads=1")
12525 .env(sentinel, "1");
12526 for name in [
12527 car_home::ENV_VAR,
12528 "CAR_SECRETS_FILE_DIR",
12529 "CAR_TEST_NATIVE_KEYCHAIN",
12530 "CAR_AUTH_LOCK_PATH",
12531 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
12532 car_auth::PARSLEE_API_BASE_KEY,
12533 crate::openrouter::API_KEY_ENV,
12534 "OPENAI_API_KEY",
12535 "ANTHROPIC_API_KEY",
12536 "GOOGLE_API_KEY",
12537 "ELEVENLABS_API_KEY",
12538 "SSL_CERT_FILE",
12539 "SSL_CERT_DIR",
12540 ] {
12541 command.env_remove(name);
12542 }
12543 const CHILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(300);
12550 let mut stdout = tempfile::tempfile().expect("create isolated-test stdout capture");
12551 let mut stderr = tempfile::tempfile().expect("create isolated-test stderr capture");
12552 command
12553 .stdin(std::process::Stdio::null())
12554 .stdout(stdout.try_clone().expect("clone stdout capture"))
12555 .stderr(stderr.try_clone().expect("clone stderr capture"));
12556 #[cfg(unix)]
12557 {
12558 use std::os::unix::process::CommandExt;
12559 command.process_group(0);
12560 }
12561
12562 let mut child = command.spawn().expect("spawn isolated inference test");
12563 let deadline = std::time::Instant::now() + CHILD_BUDGET;
12564 let mut timed_out = false;
12565 let status = loop {
12566 match child.try_wait().expect("poll isolated inference test") {
12567 Some(status) => break status,
12568 None if std::time::Instant::now() < deadline => {
12569 std::thread::sleep(std::time::Duration::from_millis(50));
12570 }
12571 None => {
12572 timed_out = true;
12573 #[cfg(unix)]
12574 unsafe {
12575 libc::kill(-(child.id() as i32), libc::SIGKILL);
12576 }
12577 let _ = child.kill();
12578 break child
12579 .wait()
12580 .expect("reap timed-out isolated inference test");
12581 }
12582 }
12583 };
12584
12585 use std::io::{Read, Seek};
12586 stdout.rewind().expect("rewind isolated-test stdout");
12587 stderr.rewind().expect("rewind isolated-test stderr");
12588 let mut stdout_bytes = Vec::new();
12589 let mut stderr_bytes = Vec::new();
12590 stdout
12591 .read_to_end(&mut stdout_bytes)
12592 .expect("read isolated-test stdout");
12593 stderr
12594 .read_to_end(&mut stderr_bytes)
12595 .expect("read isolated-test stderr");
12596 assert!(
12597 !timed_out,
12598 "isolated {test_name} exceeded {}s and was killed\nstdout:\n{}\nstderr:\n{}",
12599 CHILD_BUDGET.as_secs(),
12600 String::from_utf8_lossy(&stdout_bytes),
12601 String::from_utf8_lossy(&stderr_bytes),
12602 );
12603 assert!(
12604 status.success(),
12605 "isolated {test_name} failed\nstdout:\n{}\nstderr:\n{}",
12606 String::from_utf8_lossy(&stdout_bytes),
12607 String::from_utf8_lossy(&stderr_bytes),
12608 );
12609 false
12610}
12611
12612#[cfg(test)]
12613mod tests {
12614 use super::*;
12615 use std::ffi::OsString;
12616 use tempfile::TempDir;
12617
12618 #[test]
12625 fn local_models_keep_the_callers_default_output_budget() {
12626 use crate::tasks::generate::DEFAULT_MAX_TOKENS;
12627
12628 let catalog = crate::registry::builtin_catalog();
12629 let local = catalog
12630 .iter()
12631 .find(|s| s.id == "mlx/qwen3-8b:4bit")
12632 .expect("mlx/qwen3-8b:4bit is a builtin catalog entry");
12633
12634 assert!(local.is_local(), "mlx/* is decoded in-process");
12635 assert_eq!(
12636 local.effective_max_output(),
12637 32_768,
12638 "the budget that made the reported turn ~24 minutes long"
12639 );
12640 assert_eq!(
12641 resolved_max_tokens(DEFAULT_MAX_TOKENS, local),
12642 DEFAULT_MAX_TOKENS,
12643 "a local model must keep the budget the caller actually asked for"
12644 );
12645
12646 let mut remote = catalog
12649 .iter()
12650 .find(|s| !s.is_local())
12651 .expect("the builtin catalog ships remote models")
12652 .clone();
12653 remote.context_length = local.context_length;
12654 remote.max_output_tokens = local.max_output_tokens;
12655 assert!(!remote.is_local());
12656 assert_eq!(
12657 resolved_max_tokens(DEFAULT_MAX_TOKENS, &remote),
12658 32_768,
12659 "remote models still get their advertised output budget"
12660 );
12661
12662 assert_eq!(resolved_max_tokens(512, local), 512);
12664 assert_eq!(resolved_max_tokens(512, &remote), 512);
12665
12666 let codex = catalog
12667 .iter()
12668 .find(|s| s.id == "openai/gpt-5.6-sol:high")
12669 .expect("subscription-backed Codex row is builtin");
12670 assert_eq!(
12671 resolved_max_tokens(DEFAULT_MAX_TOKENS, codex),
12672 DEFAULT_MAX_TOKENS,
12673 "an approximate instruction must not widen the default to 128K tokens"
12674 );
12675
12676 if let Some(vllm) = catalog.iter().find(|s| s.is_vllm_mlx()) {
12682 assert!(vllm.is_local(), "vLLM-MLX runs on this machine");
12683 assert!(
12684 !vllm.decodes_in_process(),
12685 "but CAR does not decode it token by token"
12686 );
12687 assert_eq!(
12688 resolved_max_tokens(DEFAULT_MAX_TOKENS, vllm),
12689 vllm.effective_max_output(),
12690 );
12691 }
12692 }
12693
12694 #[test]
12697 fn a_ceiling_stop_fails_instead_of_retrying() {
12698 use EmptyPassAction::*;
12699 const CEILING: Option<&str> = Some(LOCAL_DECODE_TIMEOUT_STOP_REASON);
12700
12701 assert_eq!(
12704 classify_empty_pass(true, CEILING, "", true),
12705 FailDecodeCeiling
12706 );
12707 assert_eq!(
12708 classify_empty_pass(false, CEILING, "", true),
12709 FailDecodeCeiling
12710 );
12711 assert_eq!(
12713 classify_empty_pass(true, CEILING, " \n ", true),
12714 FailDecodeCeiling
12715 );
12716
12717 assert_eq!(
12720 classify_empty_pass(true, Some("length"), "", true),
12721 RetryWithoutThinking
12722 );
12723 assert_eq!(
12724 classify_empty_pass(true, None, "", true),
12725 RetryWithoutThinking
12726 );
12727
12728 assert_eq!(classify_empty_pass(false, Some("stop"), "", true), Accept);
12731
12732 assert_eq!(classify_empty_pass(true, CEILING, "partial", true), Accept);
12735 assert_eq!(classify_empty_pass(true, CEILING, "", false), Accept);
12736
12737 assert_ne!(LOCAL_DECODE_TIMEOUT_STOP_REASON, "timeout");
12741 assert_eq!(
12742 classify_empty_pass(true, Some("timeout"), "", true),
12743 RetryWithoutThinking
12744 );
12745 }
12746
12747 #[test]
12751 fn decode_deadline_and_heartbeat_predicates() {
12752 use std::time::Duration;
12753 let limit = Duration::from_secs(300);
12754
12755 assert!(!deadline_exceeded(Duration::from_secs(86_400), None));
12757
12758 assert!(!deadline_exceeded(Duration::from_secs(299), Some(limit)));
12759 assert!(deadline_exceeded(limit, Some(limit)));
12762 assert!(deadline_exceeded(Duration::from_secs(301), Some(limit)));
12763
12764 let every = Duration::from_secs(10);
12765 assert!(!heartbeat_due(
12767 Duration::from_secs(9),
12768 Duration::ZERO,
12769 every
12770 ));
12771 assert!(heartbeat_due(
12772 Duration::from_secs(10),
12773 Duration::ZERO,
12774 every
12775 ));
12776 assert!(!heartbeat_due(
12778 Duration::from_secs(11),
12779 Duration::from_secs(10),
12780 every
12781 ));
12782 assert!(heartbeat_due(
12783 Duration::from_secs(20),
12784 Duration::from_secs(10),
12785 every
12786 ));
12787 assert!(!heartbeat_due(
12789 Duration::from_secs(5),
12790 Duration::from_secs(10),
12791 every
12792 ));
12793 }
12794
12795 #[test]
12796 fn decode_timeout_parses_with_a_safe_fallback() {
12797 let default = std::time::Duration::from_secs(DEFAULT_LOCAL_DECODE_TIMEOUT_SECS);
12798 assert_eq!(parse_decode_timeout(None), Some(default));
12799 assert_eq!(
12800 parse_decode_timeout(Some(" 45 ")),
12801 Some(std::time::Duration::from_secs(45))
12802 );
12803 assert_eq!(
12804 parse_decode_timeout(Some("0")),
12805 None,
12806 "0 disables the ceiling"
12807 );
12808 assert_eq!(
12809 parse_decode_timeout(Some("banana")),
12810 Some(default),
12811 "garbage must fall back to the default, never silently disable it"
12812 );
12813 }
12814
12815 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12820 #[test]
12821 fn local_decode_stops_at_the_wall_clock_ceiling() {
12822 struct NeverStops;
12825
12826 impl crate::backend::local::TextDecoder for NeverStops {
12827 fn encode(&self, _text: &str) -> Result<Vec<u32>, InferenceError> {
12828 Ok(vec![1, 2, 3])
12829 }
12830 fn decode(&self, tokens: &[u32]) -> Result<String, InferenceError> {
12831 Ok("x".repeat(tokens.len()))
12832 }
12833 fn forward(
12834 &mut self,
12835 _tokens: &[u32],
12836 _pos: usize,
12837 ) -> Result<Vec<f32>, InferenceError> {
12838 std::thread::sleep(std::time::Duration::from_millis(5));
12839 let mut logits = vec![0.0f32; 16];
12840 logits[7] = 10.0;
12841 Ok(logits)
12842 }
12843 fn eos_ids(&self) -> Vec<u32> {
12844 vec![0]
12845 }
12846 fn context_length(&self) -> usize {
12847 4096
12848 }
12849 fn clear_kv_cache(&mut self) {}
12850 }
12851
12852 let params = GenerateParams {
12855 max_tokens: 100_000,
12856 temperature: 0.0,
12857 ..Default::default()
12858 };
12859 let ceiling = std::time::Duration::from_millis(300);
12860
12861 let mut backend = NeverStops;
12862 let started = std::time::Instant::now();
12863 let generated = match InferenceEngine::drive_generation_with_timeout(
12864 &mut backend,
12865 "anything",
12866 ¶ms,
12867 Some(ceiling),
12868 ) {
12869 Ok(generated) => generated,
12870 Err(e) => panic!(
12872 "a decode cut short by the ceiling returns its partial text, not an error: {}",
12873 e.into_inner()
12874 ),
12875 };
12876 let elapsed = started.elapsed();
12877
12878 assert_eq!(
12879 generated.stop_reason.as_deref(),
12880 Some(LOCAL_DECODE_TIMEOUT_STOP_REASON),
12881 "the caller must be able to tell a deadline stop from a clean finish"
12882 );
12883 assert!(
12884 InferenceResult {
12885 text: generated.text.clone(),
12886 bounding_boxes: Vec::new(),
12887 tool_calls: vec![],
12888 trace_id: String::new(),
12889 model_used: String::new(),
12890 model_identity: Default::default(),
12891 latency_ms: 0,
12892 time_to_first_token_ms: None,
12893 usage: None,
12894 provider_output_items: Vec::new(),
12895 thinking: Vec::new(),
12896 stop_reason: generated.stop_reason.clone(),
12897 auth_fallback_from: None,
12898 local_last_resort: false,
12899 fallback_from: Vec::new(),
12900 }
12901 .was_truncated(),
12902 "a ceiling stop is a cut-short answer, not a complete one"
12903 );
12904 assert!(
12905 generated.completion_tokens > 0,
12906 "the partial response is kept, not discarded"
12907 );
12908 assert!(
12909 generated.completion_tokens < params.max_tokens,
12910 "the loop stopped on the clock, not by exhausting the budget"
12911 );
12912 assert!(
12913 elapsed < std::time::Duration::from_secs(30),
12914 "bounded in wall clock; took {elapsed:?}"
12915 );
12916 }
12917
12918 struct RestoredEnvironment(Vec<(&'static str, Option<OsString>)>);
12919
12920 impl RestoredEnvironment {
12921 fn capture(names: &[&'static str]) -> Self {
12922 Self(
12923 names
12924 .iter()
12925 .map(|name| (*name, std::env::var_os(name)))
12926 .collect(),
12927 )
12928 }
12929 }
12930
12931 impl Drop for RestoredEnvironment {
12932 fn drop(&mut self) {
12933 for (name, value) in &self.0 {
12934 unsafe {
12935 match value {
12936 Some(value) => std::env::set_var(name, value),
12937 None => std::env::remove_var(name),
12938 }
12939 }
12940 }
12941 }
12942 }
12943
12944 struct FixtureLocalOffload {
12945 emit_done: bool,
12946 }
12947
12948 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12953 struct ExactPinCaptureOffload {
12954 dispatched_models: std::sync::Mutex<Vec<String>>,
12955 }
12956
12957 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12958 impl ExactPinCaptureOffload {
12959 fn new() -> Self {
12960 Self {
12961 dispatched_models: std::sync::Mutex::new(Vec::new()),
12962 }
12963 }
12964
12965 fn dispatched_models(&self) -> Vec<String> {
12966 self.dispatched_models.lock().unwrap().clone()
12967 }
12968 }
12969
12970 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12971 #[async_trait::async_trait]
12972 impl crate::offload::LocalGenerationOffload for ExactPinCaptureOffload {
12973 async fn generate(
12974 &self,
12975 request: GenerateRequest,
12976 ) -> Result<InferenceResult, InferenceError> {
12977 let model_id = request.model.expect("resolved worker model");
12978 self.dispatched_models
12979 .lock()
12980 .unwrap()
12981 .push(model_id.clone());
12982 Ok(InferenceResult {
12983 text: "exact pin".into(),
12984 tool_calls: vec![],
12985 bounding_boxes: vec![],
12986 trace_id: "worker-trace".into(),
12987 model_used: model_id,
12988 model_identity: Default::default(),
12989 latency_ms: 0,
12990 time_to_first_token_ms: None,
12991 usage: None,
12992 provider_output_items: vec![],
12993 thinking: vec![],
12994 stop_reason: Some("stop".into()),
12995 auth_fallback_from: None,
12996 local_last_resort: false,
12997 fallback_from: Vec::new(),
12998 })
12999 }
13000
13001 async fn stream(
13002 &self,
13003 request: GenerateRequest,
13004 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13005 let model_id = request.model.expect("resolved worker model");
13006 self.dispatched_models.lock().unwrap().push(model_id);
13007 let (tx, rx) = tokio::sync::mpsc::channel(2);
13008 tx.send(StreamEvent::Done {
13009 text: "exact pin".into(),
13010 tool_calls: vec![],
13011 })
13012 .await
13013 .unwrap();
13014 Ok(rx)
13015 }
13016 }
13017
13018 struct ReleaseRetryOffload {
13019 calls: std::sync::Mutex<Vec<String>>,
13020 model_id: String,
13021 resident: bool,
13022 release_acknowledged: bool,
13023 }
13024
13025 struct SequencedRetryProbe {
13026 calls: Arc<std::sync::atomic::AtomicUsize>,
13027 first_available_mb: u64,
13028 subsequent_available_mb: u64,
13029 }
13030
13031 impl crate::resource_policy::LiveMemoryProbe for SequencedRetryProbe {
13032 fn available_memory_mb(
13033 &self,
13034 ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
13035 let call = self
13036 .calls
13037 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
13038 Ok(Some(if call == 0 {
13039 self.first_available_mb
13040 } else {
13041 self.subsequent_available_mb
13042 }))
13043 }
13044 }
13045
13046 #[async_trait::async_trait]
13047 impl crate::offload::LocalGenerationOffload for ReleaseRetryOffload {
13048 async fn generate(
13049 &self,
13050 _request: GenerateRequest,
13051 ) -> Result<InferenceResult, InferenceError> {
13052 unreachable!("release-and-retry fixture")
13053 }
13054
13055 async fn stream(
13056 &self,
13057 _request: GenerateRequest,
13058 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13059 unreachable!("release-and-retry fixture")
13060 }
13061
13062 async fn resident_models(&self) -> Vec<String> {
13063 self.resident
13064 .then(|| self.model_id.clone())
13065 .into_iter()
13066 .collect()
13067 }
13068
13069 fn resident_allocation_id(&self, model_id: &str) -> Option<String> {
13070 Some(format!("worker:retry-fixture:{model_id}"))
13071 }
13072
13073 async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13074 self.calls
13075 .lock()
13076 .unwrap()
13077 .push(format!("release:{model_id}"));
13078 Ok(self.release_acknowledged)
13079 }
13080 }
13081
13082 struct RetiringReleaseOffload {
13083 calls: std::sync::Mutex<Vec<String>>,
13084 model_id: String,
13085 allocation_id: String,
13086 resident: std::sync::atomic::AtomicBool,
13087 coordinator: Arc<crate::resource_policy::LocalAdmissionCoordinator>,
13088 }
13089
13090 #[async_trait::async_trait]
13091 impl crate::offload::LocalGenerationOffload for RetiringReleaseOffload {
13092 async fn generate(
13093 &self,
13094 _request: GenerateRequest,
13095 ) -> Result<InferenceResult, InferenceError> {
13096 unreachable!("retiring-release fixture")
13097 }
13098
13099 async fn stream(
13100 &self,
13101 _request: GenerateRequest,
13102 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13103 unreachable!("retiring-release fixture")
13104 }
13105
13106 async fn resident_models(&self) -> Vec<String> {
13107 self.resident
13108 .load(std::sync::atomic::Ordering::Acquire)
13109 .then(|| self.model_id.clone())
13110 .into_iter()
13111 .collect()
13112 }
13113
13114 fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
13115 Some(self.allocation_id.clone())
13116 }
13117
13118 async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13119 self.calls
13120 .lock()
13121 .unwrap()
13122 .push(format!("release:{model_id}"));
13123 self.coordinator
13124 .mark_teardown_pending_allocation(model_id, &self.allocation_id);
13125 self.coordinator
13126 .finish_teardown_allocation(model_id, &self.allocation_id);
13127 self.resident
13128 .store(false, std::sync::atomic::Ordering::Release);
13129 Ok(true)
13130 }
13131 }
13132
13133 #[tokio::test]
13134 async fn release_and_retry_releases_resident_worker_after_live_memory_refusal() {
13135 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13136 let root = tempfile::tempdir().unwrap();
13137 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13138 let schema = crate::registry::builtin_catalog()
13139 .into_iter()
13140 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13141 .unwrap();
13142 let allocation_id = format!("worker:retry-fixture:{}", schema.id);
13143 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13144 let coordinator = Arc::new(
13145 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13146 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13147 metal_mac_for_fit(32),
13148 Arc::new(SequencedRetryProbe {
13149 calls: probe_calls.clone(),
13150 first_available_mb: 1_000,
13151 subsequent_available_mb: 1_048_576,
13152 }),
13153 ),
13154 );
13155 coordinator.mark_resident_allocation(&schema.id, &allocation_id, 4_800);
13156 engine.local_admission = coordinator.clone();
13157 let offload = Arc::new(RetiringReleaseOffload {
13158 calls: std::sync::Mutex::new(Vec::new()),
13159 model_id: schema.id.clone(),
13160 allocation_id,
13161 resident: std::sync::atomic::AtomicBool::new(true),
13162 coordinator,
13163 });
13164 crate::offload::set_local_offload(Some(offload.clone()));
13165
13166 let result = engine
13167 .reserve_local_request_with_worker_retry(&schema, 9_000)
13168 .await;
13169 crate::offload::set_local_offload(None);
13170
13171 assert!(
13172 result.is_ok(),
13173 "release followed by a second probe should admit"
13174 );
13175 assert_eq!(
13176 *offload.calls.lock().unwrap(),
13177 vec![format!("release:{}", schema.id)]
13178 );
13179 assert_eq!(
13180 probe_calls.load(std::sync::atomic::Ordering::Relaxed),
13181 2,
13182 "admission must probe exactly once before and once after release"
13183 );
13184 }
13185
13186 #[tokio::test]
13187 async fn release_and_retry_ignores_sibling_allocation_after_exact_worker_retirement() {
13188 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13189 let root = tempfile::tempdir().unwrap();
13190 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13191 let schema = crate::registry::builtin_catalog()
13192 .into_iter()
13193 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13194 .unwrap();
13195 let worker_allocation_id = format!("worker:sibling-test:{}", schema.id);
13196 let sibling_allocation_id = format!("cache:sibling-test:{}", schema.id);
13197 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13198 let coordinator = Arc::new(
13199 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13200 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13201 metal_mac_for_fit(32),
13202 Arc::new(SequencedRetryProbe {
13203 calls: probe_calls.clone(),
13204 first_available_mb: 1_000,
13205 subsequent_available_mb: 1_048_576,
13206 }),
13207 ),
13208 );
13209 coordinator.mark_resident_allocation(&schema.id, &worker_allocation_id, 4_800);
13210 coordinator.mark_resident_allocation(&schema.id, &sibling_allocation_id, 500);
13211 engine.local_admission = coordinator.clone();
13212 let offload = Arc::new(RetiringReleaseOffload {
13213 calls: std::sync::Mutex::new(Vec::new()),
13214 model_id: schema.id.clone(),
13215 allocation_id: worker_allocation_id.clone(),
13216 resident: std::sync::atomic::AtomicBool::new(true),
13217 coordinator: coordinator.clone(),
13218 });
13219 crate::offload::set_local_offload(Some(offload.clone()));
13220
13221 let result = engine
13222 .reserve_local_request_with_worker_retry(&schema, 9_000)
13223 .await;
13224 crate::offload::set_local_offload(None);
13225
13226 assert!(
13227 result.is_ok(),
13228 "an unrelated resident allocation must not block the worker retry"
13229 );
13230 assert_eq!(
13231 coordinator.resident_allocation_ids(&schema.id),
13232 vec![sibling_allocation_id]
13233 );
13234 assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 2);
13235 assert_eq!(
13236 *offload.calls.lock().unwrap(),
13237 vec![format!("release:{}", schema.id)]
13238 );
13239 }
13240
13241 struct UnretiredReleaseOffload {
13242 calls: std::sync::Mutex<Vec<String>>,
13243 model_id: String,
13244 allocation_id: String,
13245 resident: std::sync::atomic::AtomicBool,
13246 }
13247
13248 #[async_trait::async_trait]
13249 impl crate::offload::LocalGenerationOffload for UnretiredReleaseOffload {
13250 async fn generate(
13251 &self,
13252 _request: GenerateRequest,
13253 ) -> Result<InferenceResult, InferenceError> {
13254 unreachable!("unretired-release fixture")
13255 }
13256
13257 async fn stream(
13258 &self,
13259 _request: GenerateRequest,
13260 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13261 unreachable!("unretired-release fixture")
13262 }
13263
13264 async fn resident_models(&self) -> Vec<String> {
13265 self.resident
13266 .load(std::sync::atomic::Ordering::Acquire)
13267 .then(|| self.model_id.clone())
13268 .into_iter()
13269 .collect()
13270 }
13271
13272 fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
13273 Some(self.allocation_id.clone())
13274 }
13275
13276 async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13277 self.calls
13278 .lock()
13279 .unwrap()
13280 .push(format!("release:{model_id}"));
13281 self.resident
13282 .store(false, std::sync::atomic::Ordering::Release);
13283 Ok(true)
13284 }
13285 }
13286
13287 #[tokio::test]
13288 async fn release_and_retry_rejects_ack_without_accounting_retirement() {
13289 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13290 let warnings_before =
13291 UNRETIRED_RELEASE_WARNING_COUNT.load(std::sync::atomic::Ordering::Relaxed);
13292 let root = tempfile::tempdir().unwrap();
13293 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13294 let schema = crate::registry::builtin_catalog()
13295 .into_iter()
13296 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13297 .unwrap();
13298 let allocation_id = format!("worker:phantom:{}", schema.id);
13299 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13300 let coordinator = Arc::new(
13301 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13302 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13303 metal_mac_for_fit(32),
13304 Arc::new(SequencedRetryProbe {
13305 calls: probe_calls.clone(),
13306 first_available_mb: 1_000,
13307 subsequent_available_mb: 8_000,
13308 }),
13309 ),
13310 );
13311 coordinator.mark_resident_allocation(&schema.id, &allocation_id, 4_800);
13312 engine.local_admission = coordinator;
13313 let offload = Arc::new(UnretiredReleaseOffload {
13314 calls: std::sync::Mutex::new(Vec::new()),
13315 model_id: schema.id.clone(),
13316 allocation_id,
13317 resident: std::sync::atomic::AtomicBool::new(true),
13318 });
13319 crate::offload::set_local_offload(Some(offload.clone()));
13320
13321 let result = engine
13322 .reserve_local_request_with_worker_retry(&schema, 9_000)
13323 .await;
13324 crate::offload::set_local_offload(None);
13325
13326 assert!(matches!(
13327 result,
13328 Err(InferenceError::LocalResourceBlocked { preflight, .. })
13329 if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13330 ));
13331 assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13332 assert_eq!(
13333 UNRETIRED_RELEASE_WARNING_COUNT.load(std::sync::atomic::Ordering::Relaxed),
13334 warnings_before + 1,
13335 "the refused retry must emit the stale-residency warning"
13336 );
13337 assert!(engine.local_admission.is_resident(&schema.id));
13338 assert!(offload.resident_models().await.is_empty());
13339 assert_eq!(
13340 *offload.calls.lock().unwrap(),
13341 vec![format!("release:{}", schema.id)]
13342 );
13343 }
13344
13345 struct PendingTeardownAckOffload {
13346 calls: std::sync::Mutex<Vec<String>>,
13347 model_id: String,
13348 allocation_id: String,
13349 resident: std::sync::atomic::AtomicBool,
13350 coordinator: Arc<crate::resource_policy::LocalAdmissionCoordinator>,
13351 }
13352
13353 #[async_trait::async_trait]
13354 impl crate::offload::LocalGenerationOffload for PendingTeardownAckOffload {
13355 async fn generate(
13356 &self,
13357 _request: GenerateRequest,
13358 ) -> Result<InferenceResult, InferenceError> {
13359 unreachable!("pending-teardown ACK fixture")
13360 }
13361
13362 async fn stream(
13363 &self,
13364 _request: GenerateRequest,
13365 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13366 unreachable!("pending-teardown ACK fixture")
13367 }
13368
13369 async fn resident_models(&self) -> Vec<String> {
13370 self.resident
13371 .load(std::sync::atomic::Ordering::Acquire)
13372 .then(|| self.model_id.clone())
13373 .into_iter()
13374 .collect()
13375 }
13376
13377 fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
13378 Some(self.allocation_id.clone())
13379 }
13380
13381 async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13382 self.calls
13383 .lock()
13384 .unwrap()
13385 .push(format!("release:{model_id}"));
13386 self.coordinator
13387 .mark_teardown_pending_allocation_with_charge(
13388 model_id,
13389 &self.allocation_id,
13390 4_800 * 1024 * 1024,
13391 );
13392 self.resident
13393 .store(false, std::sync::atomic::Ordering::Release);
13394 Ok(true)
13395 }
13396 }
13397
13398 #[tokio::test]
13399 async fn release_and_retry_rejects_ack_with_exact_worker_pending_teardown() {
13400 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13401 let warnings_before =
13402 UNRETIRED_RELEASE_WARNING_COUNT.load(std::sync::atomic::Ordering::Relaxed);
13403 let root = tempfile::tempdir().unwrap();
13404 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13405 let schema = crate::registry::builtin_catalog()
13406 .into_iter()
13407 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13408 .unwrap();
13409 let allocation_id = format!("worker:pending-test:{}", schema.id);
13410 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13411 let coordinator = Arc::new(
13412 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13413 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13414 metal_mac_for_fit(32),
13415 Arc::new(SequencedRetryProbe {
13416 calls: probe_calls.clone(),
13417 first_available_mb: 1_000,
13418 subsequent_available_mb: 8_000,
13419 }),
13420 ),
13421 );
13422 engine.local_admission = coordinator.clone();
13423 let offload = Arc::new(PendingTeardownAckOffload {
13424 calls: std::sync::Mutex::new(Vec::new()),
13425 model_id: schema.id.clone(),
13426 allocation_id: allocation_id.clone(),
13427 resident: std::sync::atomic::AtomicBool::new(true),
13428 coordinator: coordinator.clone(),
13429 });
13430 crate::offload::set_local_offload(Some(offload.clone()));
13431
13432 let result = engine
13433 .reserve_local_request_with_worker_retry(&schema, 9_000)
13434 .await;
13435 crate::offload::set_local_offload(None);
13436
13437 assert!(matches!(
13438 result,
13439 Err(InferenceError::LocalResourceBlocked { preflight, .. })
13440 if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13441 ));
13442 assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13443 assert_eq!(
13444 UNRETIRED_RELEASE_WARNING_COUNT.load(std::sync::atomic::Ordering::Relaxed),
13445 warnings_before + 1,
13446 "the pending exact allocation must emit the fail-closed warning"
13447 );
13448 assert_eq!(
13449 coordinator.resident_allocation_ids(&schema.id),
13450 vec![allocation_id]
13451 );
13452 assert!(offload.resident_models().await.is_empty());
13453 assert_eq!(
13454 *offload.calls.lock().unwrap(),
13455 vec![format!("release:{}", schema.id)]
13456 );
13457 }
13458
13459 struct MissingAllocationIdOffload {
13460 calls: std::sync::Mutex<Vec<String>>,
13461 model_id: String,
13462 }
13463
13464 #[async_trait::async_trait]
13465 impl crate::offload::LocalGenerationOffload for MissingAllocationIdOffload {
13466 async fn generate(
13467 &self,
13468 _request: GenerateRequest,
13469 ) -> Result<InferenceResult, InferenceError> {
13470 unreachable!("missing allocation-id fixture")
13471 }
13472
13473 async fn stream(
13474 &self,
13475 _request: GenerateRequest,
13476 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13477 unreachable!("missing allocation-id fixture")
13478 }
13479
13480 async fn resident_models(&self) -> Vec<String> {
13481 vec![self.model_id.clone()]
13482 }
13483
13484 async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13485 self.calls
13486 .lock()
13487 .unwrap()
13488 .push(format!("release:{model_id}"));
13489 Ok(true)
13490 }
13491 }
13492
13493 #[tokio::test]
13494 async fn release_and_retry_without_scoped_allocation_id_fails_closed() {
13495 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13496 let root = tempfile::tempdir().unwrap();
13497 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13498 let schema = crate::registry::builtin_catalog()
13499 .into_iter()
13500 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13501 .unwrap();
13502 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13503 engine.local_admission = Arc::new(
13504 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13505 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13506 metal_mac_for_fit(32),
13507 Arc::new(SequencedRetryProbe {
13508 calls: probe_calls.clone(),
13509 first_available_mb: 1_000,
13510 subsequent_available_mb: 8_000,
13511 }),
13512 ),
13513 );
13514 let offload = Arc::new(MissingAllocationIdOffload {
13515 calls: std::sync::Mutex::new(Vec::new()),
13516 model_id: schema.id.clone(),
13517 });
13518 crate::offload::set_local_offload(Some(offload.clone()));
13519
13520 let result = engine
13521 .reserve_local_request_with_worker_retry(&schema, 9_000)
13522 .await;
13523 crate::offload::set_local_offload(None);
13524
13525 assert!(matches!(
13526 result,
13527 Err(InferenceError::LocalResourceBlocked { preflight, .. })
13528 if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13529 ));
13530 assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13531 assert!(offload.calls.lock().unwrap().is_empty());
13532 }
13533
13534 struct ReplacingResidentOffload {
13535 calls: std::sync::Mutex<Vec<String>>,
13536 model_id: String,
13537 allocation_id: String,
13538 coordinator: Arc<crate::resource_policy::LocalAdmissionCoordinator>,
13539 }
13540
13541 #[async_trait::async_trait]
13542 impl crate::offload::LocalGenerationOffload for ReplacingResidentOffload {
13543 async fn generate(
13544 &self,
13545 _request: GenerateRequest,
13546 ) -> Result<InferenceResult, InferenceError> {
13547 unreachable!("replacement-resident fixture")
13548 }
13549
13550 async fn stream(
13551 &self,
13552 _request: GenerateRequest,
13553 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13554 unreachable!("replacement-resident fixture")
13555 }
13556
13557 async fn resident_models(&self) -> Vec<String> {
13558 vec![self.model_id.clone()]
13559 }
13560
13561 fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
13562 Some(self.allocation_id.clone())
13563 }
13564
13565 async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13566 self.calls
13567 .lock()
13568 .unwrap()
13569 .push(format!("release:{model_id}"));
13570 self.coordinator
13574 .mark_teardown_pending_allocation(model_id, &self.allocation_id);
13575 self.coordinator
13576 .finish_teardown_allocation(model_id, &self.allocation_id);
13577 self.coordinator
13578 .mark_resident_allocation(model_id, &self.allocation_id, 4_800);
13579 Ok(true)
13580 }
13581 }
13582
13583 #[tokio::test]
13584 async fn release_and_retry_same_id_replacement_blocks_retry_without_erasing_accounting() {
13585 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13586 let root = tempfile::tempdir().unwrap();
13587 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13588 let schema = crate::registry::builtin_catalog()
13589 .into_iter()
13590 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13591 .unwrap();
13592 let allocation_id = format!("worker:reused-scope:{}", schema.id);
13593 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13594 let coordinator = Arc::new(
13595 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13596 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13597 metal_mac_for_fit(32),
13598 Arc::new(SequencedRetryProbe {
13599 calls: probe_calls.clone(),
13600 first_available_mb: 1_000,
13601 subsequent_available_mb: 8_000,
13602 }),
13603 ),
13604 );
13605 coordinator.mark_resident_allocation(&schema.id, &allocation_id, 4_800);
13606 engine.local_admission = coordinator.clone();
13607 let offload = Arc::new(ReplacingResidentOffload {
13608 calls: std::sync::Mutex::new(Vec::new()),
13609 model_id: schema.id.clone(),
13610 allocation_id,
13611 coordinator,
13612 });
13613 crate::offload::set_local_offload(Some(offload.clone()));
13614
13615 let result = engine
13616 .reserve_local_request_with_worker_retry(&schema, 9_000)
13617 .await;
13618 crate::offload::set_local_offload(None);
13619
13620 assert!(matches!(
13621 result,
13622 Err(InferenceError::LocalResourceBlocked { preflight, .. })
13623 if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13624 ));
13625 assert!(engine.local_admission.is_resident(&schema.id));
13626 assert_eq!(
13627 *offload.calls.lock().unwrap(),
13628 vec![format!("release:{}", schema.id)]
13629 );
13630 assert_eq!(
13631 probe_calls.load(std::sync::atomic::Ordering::Relaxed),
13632 1,
13633 "replacement residency must stop the retry before a second probe"
13634 );
13635 }
13636
13637 #[tokio::test]
13638 async fn release_and_retry_nonresident_worker_returns_original_refusal() {
13639 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13640 let root = tempfile::tempdir().unwrap();
13641 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13642 let schema = crate::registry::builtin_catalog()
13643 .into_iter()
13644 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13645 .unwrap();
13646 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13647 engine.local_admission = Arc::new(
13648 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13649 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13650 metal_mac_for_fit(32),
13651 Arc::new(SequencedRetryProbe {
13652 calls: probe_calls.clone(),
13653 first_available_mb: 1_000,
13654 subsequent_available_mb: 1_048_576,
13655 }),
13656 ),
13657 );
13658 let offload = Arc::new(ReleaseRetryOffload {
13659 calls: std::sync::Mutex::new(Vec::new()),
13660 model_id: schema.id.clone(),
13661 resident: false,
13662 release_acknowledged: true,
13663 });
13664 crate::offload::set_local_offload(Some(offload.clone()));
13665
13666 let result = engine
13667 .reserve_local_request_with_worker_retry(&schema, 9_000)
13668 .await;
13669 crate::offload::set_local_offload(None);
13670
13671 assert!(matches!(
13672 result,
13673 Err(InferenceError::LocalResourceBlocked { preflight, .. })
13674 if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13675 ));
13676 assert!(offload.calls.lock().unwrap().is_empty());
13677 assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13678 }
13679
13680 #[tokio::test]
13681 async fn release_and_retry_unacknowledged_release_does_not_reprobe() {
13682 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13683 let root = tempfile::tempdir().unwrap();
13684 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13685 let schema = crate::registry::builtin_catalog()
13686 .into_iter()
13687 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13688 .unwrap();
13689 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13690 engine.local_admission = Arc::new(
13691 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13692 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13693 metal_mac_for_fit(32),
13694 Arc::new(SequencedRetryProbe {
13695 calls: probe_calls.clone(),
13696 first_available_mb: 1_000,
13697 subsequent_available_mb: 1_048_576,
13698 }),
13699 ),
13700 );
13701 let offload = Arc::new(ReleaseRetryOffload {
13702 calls: std::sync::Mutex::new(Vec::new()),
13703 model_id: schema.id.clone(),
13704 resident: true,
13705 release_acknowledged: false,
13706 });
13707 crate::offload::set_local_offload(Some(offload.clone()));
13708
13709 let result = engine
13710 .reserve_local_request_with_worker_retry(&schema, 9_000)
13711 .await;
13712 crate::offload::set_local_offload(None);
13713
13714 assert!(matches!(
13715 result,
13716 Err(InferenceError::LocalResourceBlocked { preflight, .. })
13717 if preflight.verdict == resource_policy::LocalLoadVerdict::InsufficientLiveMemory
13718 ));
13719 assert_eq!(
13720 *offload.calls.lock().unwrap(),
13721 vec![format!("release:{}", schema.id)]
13722 );
13723 assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13724 }
13725
13726 #[tokio::test]
13727 async fn release_and_retry_allowed_first_verdict_never_releases() {
13728 let _offload_guard = crate::offload::test_offload_lock().lock().await;
13729 let root = tempfile::tempdir().unwrap();
13730 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
13731 let schema = crate::registry::builtin_catalog()
13732 .into_iter()
13733 .find(|model| model.id == "mlx/qwen3-8b:4bit")
13734 .unwrap();
13735 let probe_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13736 engine.local_admission = Arc::new(
13737 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13738 crate::resource_policy::ResourcePolicy::local_focused(),
13739 metal_mac_for_fit(64),
13740 Arc::new(SequencedRetryProbe {
13741 calls: probe_calls.clone(),
13742 first_available_mb: 1_048_576,
13743 subsequent_available_mb: 1_048_576,
13744 }),
13745 ),
13746 );
13747 let offload = Arc::new(ReleaseRetryOffload {
13748 calls: std::sync::Mutex::new(Vec::new()),
13749 model_id: schema.id.clone(),
13750 resident: true,
13751 release_acknowledged: true,
13752 });
13753 crate::offload::set_local_offload(Some(offload.clone()));
13754
13755 let result = engine
13756 .reserve_local_request_with_worker_retry(&schema, 9_000)
13757 .await;
13758 crate::offload::set_local_offload(None);
13759
13760 assert!(result.is_ok());
13761 assert!(offload.calls.lock().unwrap().is_empty());
13762 assert_eq!(probe_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
13763 }
13764
13765 struct ReconcileOffload {
13766 calls: std::sync::Mutex<Vec<String>>,
13767 release_acknowledged: bool,
13768 }
13769
13770 #[async_trait::async_trait]
13771 impl crate::offload::LocalGenerationOffload for ReconcileOffload {
13772 async fn generate(
13773 &self,
13774 _request: GenerateRequest,
13775 ) -> Result<InferenceResult, InferenceError> {
13776 unreachable!("residency reconciliation fixture")
13777 }
13778
13779 async fn stream(
13780 &self,
13781 _request: GenerateRequest,
13782 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13783 unreachable!("residency reconciliation fixture")
13784 }
13785
13786 fn resident_allocation_id(&self, model_id: &str) -> Option<String> {
13787 self.calls
13788 .lock()
13789 .unwrap()
13790 .push(format!("allocation:{model_id}"));
13791 Some(format!("worker:{model_id}:generation-7"))
13792 }
13793
13794 async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
13795 self.calls
13796 .lock()
13797 .unwrap()
13798 .push(format!("release:{model_id}"));
13799 Ok(self.release_acknowledged)
13800 }
13801 }
13802
13803 fn reconciliation_reservation() -> (
13804 Arc<crate::resource_policy::LocalAdmissionCoordinator>,
13805 crate::resource_policy::LocalLoadReservation,
13806 ) {
13807 struct FixedProbe;
13808 impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
13809 fn available_memory_mb(
13810 &self,
13811 ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
13812 Ok(Some(24_000))
13813 }
13814 }
13815 let coordinator = Arc::new(
13816 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
13817 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
13818 crate::hardware::HardwareInfo {
13819 total_ram_mb: 32 * 1024,
13820 ..crate::hardware::HardwareInfo::detect()
13821 },
13822 Arc::new(FixedProbe),
13823 ),
13824 );
13825 let reservation = coordinator
13826 .reserve_measured_host_allocation(
13827 "expected/model",
13828 "expected/model#request",
13829 1024 * 1024,
13830 0,
13831 )
13832 .unwrap();
13833 (coordinator, reservation)
13834 }
13835
13836 #[tokio::test]
13837 async fn worker_residency_reconciliation_releases_mismatched_ack_before_returning_error() {
13838 let offload = ReconcileOffload {
13839 calls: std::sync::Mutex::new(Vec::new()),
13840 release_acknowledged: true,
13841 };
13842 let (_coordinator, mut reservation) = reconciliation_reservation();
13843 let error = InferenceEngine::reconcile_worker_residency(
13844 &offload,
13845 "expected/model",
13846 &crate::offload::LocalWorkerResidency {
13847 model_id: "wrong/model".into(),
13848 measured_weights_bytes: 2 * 1024 * 1024,
13849 },
13850 backend_cache::BackendRetention::Resident,
13851 &mut reservation,
13852 )
13853 .await
13854 .unwrap_err();
13855 assert!(error.to_string().contains("wrong/model"));
13856 assert_eq!(
13857 *offload.calls.lock().unwrap(),
13858 vec!["release:wrong/model".to_string()]
13859 );
13860 }
13861
13862 #[tokio::test]
13863 async fn worker_residency_reconciliation_publishes_exact_owner_for_matching_ack() {
13864 let offload = ReconcileOffload {
13865 calls: std::sync::Mutex::new(Vec::new()),
13866 release_acknowledged: false,
13867 };
13868 let (coordinator, mut reservation) = reconciliation_reservation();
13869 InferenceEngine::reconcile_worker_residency(
13870 &offload,
13871 "expected/model",
13872 &crate::offload::LocalWorkerResidency {
13873 model_id: "expected/model".into(),
13874 measured_weights_bytes: 2 * 1024 * 1024,
13875 },
13876 backend_cache::BackendRetention::Resident,
13877 &mut reservation,
13878 )
13879 .await
13880 .unwrap();
13881 drop(reservation);
13882 assert_eq!(
13883 *offload.calls.lock().unwrap(),
13884 vec!["allocation:expected/model".to_string()]
13885 );
13886 assert!(coordinator.is_resident("expected/model"));
13887 }
13888
13889 #[async_trait::async_trait]
13890 impl crate::offload::LocalGenerationOffload for FixtureLocalOffload {
13891 async fn generate(
13892 &self,
13893 _request: GenerateRequest,
13894 ) -> Result<InferenceResult, InferenceError> {
13895 unreachable!("streaming fixture")
13896 }
13897
13898 async fn stream(
13899 &self,
13900 _request: GenerateRequest,
13901 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
13902 unreachable!("admission-aware streaming fixture")
13903 }
13904
13905 async fn stream_admitted(
13906 &self,
13907 request: GenerateRequest,
13908 admission: crate::offload::LocalWorkerAdmission,
13909 ) -> Result<crate::offload::LocalOffloadStream, InferenceError> {
13910 let (tx, rx) = tokio::sync::mpsc::channel(4);
13911 let emit_done = self.emit_done;
13912 tokio::spawn(async move {
13913 let _ = tx.send(StreamEvent::TextDelta("local answer".into())).await;
13914 if emit_done {
13915 let _ = tx
13916 .send(StreamEvent::Done {
13917 text: "local answer".into(),
13918 tool_calls: vec![],
13919 })
13920 .await;
13921 }
13922 });
13923 Ok(crate::offload::LocalOffloadStream {
13924 events: rx,
13925 residency: crate::offload::LocalWorkerResidency {
13926 model_id: request.model.unwrap_or_else(|| "fixture/local".into()),
13927 measured_weights_bytes: admission.measured_weights_bytes,
13928 },
13929 retention: backend_cache::BackendRetention::Resident,
13930 })
13931 }
13932 }
13933
13934 fn pin_test_local_admission_memory(engine: &mut InferenceEngine) {
13942 let policy = engine.local_admission.policy();
13943 let mut hardware = HardwareInfo::detect();
13944 hardware.total_ram_mb = hardware.total_ram_mb.max(16 * 1024);
13945 hardware.max_model_mb = hardware.max_model_mb.max(16 * 1024);
13946 engine.local_admission = Arc::new(resource_policy::LocalAdmissionCoordinator::with_probe(
13947 policy,
13948 hardware,
13949 Arc::new(resource_policy::FixedLiveMemoryProbe::known(1_048_576)),
13950 ));
13951 }
13952
13953 fn install_small_local_fixture(engine: &InferenceEngine) -> String {
13954 let schema = engine
13955 .unified_registry
13956 .find_by_name("Qwen3-0.6B")
13957 .expect("small built-in local model")
13958 .clone();
13959 let model_dir = engine.config.models_dir.join(&schema.name);
13960 std::fs::create_dir_all(&model_dir).unwrap();
13961 std::fs::write(model_dir.join("model.gguf"), b"fixture").unwrap();
13962 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
13963 schema.id
13964 }
13965
13966 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
13967 fn install_exact_pin_equivalent_fixture(engine: &InferenceEngine) -> String {
13968 let gguf = engine
13969 .unified_registry
13970 .get("qwen/qwen3-0.6b:q8_0")
13971 .expect("GGUF fixture row")
13972 .clone();
13973 let mlx = engine
13974 .unified_registry
13975 .get("mlx/qwen3-0.6b:6bit")
13976 .expect("MLX equivalent fixture row")
13977 .clone();
13978
13979 let gguf_dir = engine.config.models_dir.join(&gguf.name);
13980 std::fs::create_dir_all(&gguf_dir).unwrap();
13981 std::fs::write(gguf_dir.join("model.gguf"), b"fixture").unwrap();
13982 std::fs::write(gguf_dir.join("tokenizer.json"), b"{}").unwrap();
13983
13984 let mlx_dir = engine.config.models_dir.join(&mlx.name);
13985 std::fs::create_dir_all(&mlx_dir).unwrap();
13986 std::fs::write(mlx_dir.join("config.json"), b"{}").unwrap();
13987 std::fs::write(mlx_dir.join("model.safetensors"), b"fixture").unwrap();
13988
13989 gguf.id
13990 }
13991
13992 fn remote_stream_fixture_schema(
13993 id: &str,
13994 endpoint: String,
13995 protocol: schema::ApiProtocol,
13996 api_key_env: &str,
13997 ) -> ModelSchema {
13998 ModelSchema {
13999 id: id.into(),
14000 name: "gemini-test".into(),
14001 provider: "test".into(),
14002 family: "test".into(),
14003 version: "1".into(),
14004 capabilities: vec![ModelCapability::Generate],
14005 context_length: 128_000,
14006 max_output_tokens: Some(8_192),
14007 param_count: String::new(),
14008 quantization: None,
14009 performance: Default::default(),
14010 cost: Default::default(),
14011 source: ModelSource::RemoteApi {
14012 endpoint,
14013 api_key_env: api_key_env.into(),
14014 api_key_envs: vec![],
14015 api_version: None,
14016 protocol,
14017 },
14018 tags: vec!["test".into()],
14019 supported_params: vec![],
14020 public_benchmarks: vec![],
14021 trust_tier: TrustTier::Community,
14022 deprecated: false,
14023 available: true,
14024 weights_ready: true,
14025 }
14026 }
14027
14028 async fn assert_remote_model_identity_contract(
14029 protocol: schema::ApiProtocol,
14030 model_id: &str,
14031 provider_model: &str,
14032 api_key_env: &str,
14033 ) {
14034 use wiremock::matchers::{method, path};
14035 use wiremock::{Mock, MockServer, ResponseTemplate};
14036
14037 let server = MockServer::start().await;
14038 let (endpoint_path, response) = match protocol {
14039 schema::ApiProtocol::OpenAiCompat => (
14040 "/v1/chat/completions",
14041 serde_json::json!({
14042 "choices": [{
14043 "message": {"content": "openai exact"},
14044 "finish_reason": "stop"
14045 }],
14046 "usage": {"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4}
14047 }),
14048 ),
14049 schema::ApiProtocol::Anthropic => (
14050 "/v1/messages",
14051 serde_json::json!({
14052 "content": [{"type": "text", "text": "anthropic exact"}],
14053 "stop_reason": "end_turn",
14054 "usage": {"input_tokens": 2, "output_tokens": 2}
14055 }),
14056 ),
14057 _ => unreachable!("identity regression covers the newsroom's two remote providers"),
14058 };
14059 Mock::given(method("POST"))
14060 .and(path(endpoint_path))
14061 .respond_with(ResponseTemplate::new(200).set_body_json(response))
14062 .mount(&server)
14063 .await;
14064 unsafe { std::env::set_var(api_key_env, "fixture") };
14065
14066 let tmp = TempDir::new().unwrap();
14067 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
14068 let mut schema =
14069 remote_stream_fixture_schema(model_id, server.uri(), protocol, api_key_env);
14070 schema.name = provider_model.into();
14071 engine.register_model(schema);
14072
14073 let mut exact_request = GenerateRequest {
14074 prompt: "return the exact identity".into(),
14075 ..Default::default()
14076 };
14077 pin_exact_model_id(&mut exact_request, model_id.into()).unwrap();
14078 let exact = engine.generate_tracked(exact_request).await.unwrap();
14079 assert_eq!(exact.model_used, model_id);
14080 assert_eq!(
14081 exact.model_identity.requested_model_id.as_deref(),
14082 Some(model_id)
14083 );
14084 assert_eq!(exact.model_identity.resolved_model_id, model_id);
14085 let exact_envelope = serde_json::to_value(&exact).unwrap();
14086 assert_eq!(exact_envelope["model_used"], model_id);
14087 assert_eq!(exact_envelope["requested_model_id"], model_id);
14088 assert_eq!(exact_envelope["resolved_model_id"], model_id);
14089 assert!(exact_envelope["row_digest"].is_string());
14090 assert!(exact_envelope["catalog_revision"].is_string());
14091
14092 let loose = engine
14093 .generate_tracked(GenerateRequest {
14094 prompt: "keep legacy display-name routing".into(),
14095 model: Some(provider_model.into()),
14096 params: GenerateParams {
14097 strict_model: true,
14098 ..Default::default()
14099 },
14100 ..Default::default()
14101 })
14102 .await
14103 .unwrap();
14104 assert_eq!(loose.model_used, provider_model);
14105 assert_eq!(loose.model_identity.requested_model_id, None);
14106 assert_eq!(loose.model_identity.resolved_model_id, model_id);
14107
14108 let requests = server.received_requests().await.unwrap();
14109 assert_eq!(requests.len(), 2);
14110 for request in requests {
14111 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
14112 assert_eq!(body["model"], provider_model);
14113 }
14114
14115 unsafe { std::env::remove_var(api_key_env) };
14116 }
14117
14118 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
14124 #[tokio::test]
14125 async fn mlx_device_lock_is_singleton_and_serializes() {
14126 use std::sync::atomic::{AtomicUsize, Ordering};
14127 use std::sync::Arc;
14128 assert!(
14130 Arc::ptr_eq(
14131 &InferenceEngine::mlx_device_lock(),
14132 &InferenceEngine::mlx_device_lock()
14133 ),
14134 "device lock must be a process-wide singleton"
14135 );
14136 let inside = Arc::new(AtomicUsize::new(0));
14138 let peak = Arc::new(AtomicUsize::new(0));
14139 let mut handles = Vec::new();
14140 for _ in 0..8 {
14141 let inside = inside.clone();
14142 let peak = peak.clone();
14143 handles.push(tokio::spawn(async move {
14144 let _g = InferenceEngine::mlx_device_lock().lock_owned().await;
14145 let n = inside.fetch_add(1, Ordering::SeqCst) + 1;
14146 peak.fetch_max(n, Ordering::SeqCst);
14147 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
14148 inside.fetch_sub(1, Ordering::SeqCst);
14149 }));
14150 }
14151 for h in handles {
14152 h.await.unwrap();
14153 }
14154 assert_eq!(
14155 peak.load(Ordering::SeqCst),
14156 1,
14157 "at most one MLX device holder at a time"
14158 );
14159 }
14160
14161 #[test]
14166 fn auto_thinking_budget_gates_code_complex_and_capability() {
14167 assert_eq!(auto_thinking_budget(true, false, true), Some(24_000));
14169 assert_eq!(auto_thinking_budget(false, true, true), Some(8_000));
14171 assert_eq!(auto_thinking_budget(true, true, true), Some(24_000));
14173 assert_eq!(auto_thinking_budget(true, true, false), None);
14175 assert_eq!(auto_thinking_budget(false, false, true), None);
14177 }
14178
14179 #[test]
14180 fn is_explicit_code_intent_keys_on_caller_intent_not_keyword_classifier() {
14181 use crate::intent::{IntentHint, TaskHint};
14182 let code = IntentHint {
14184 task: Some(TaskHint::Code),
14185 ..Default::default()
14186 };
14187 assert!(is_explicit_code_intent(Some(&code)));
14188 assert!(!is_explicit_code_intent(None));
14193 let reasoning = IntentHint {
14195 task: Some(TaskHint::Reasoning),
14196 ..Default::default()
14197 };
14198 assert!(!is_explicit_code_intent(Some(&reasoning)));
14199 let unset = IntentHint {
14201 task: None,
14202 ..Default::default()
14203 };
14204 assert!(!is_explicit_code_intent(Some(&unset)));
14205 }
14206
14207 #[test]
14208 fn strict_model_suppresses_the_local_last_resort_append() {
14209 assert!(should_append_local_last_resort(false, false));
14211 assert!(!should_append_local_last_resort(false, true));
14214 assert!(!should_append_local_last_resort(true, false));
14217 assert!(!should_append_local_last_resort(true, true));
14218 }
14219
14220 #[test]
14221 fn last_resort_fallback_never_returns_an_unrunnable_apple_foundation() {
14222 let tmp = TempDir::new().unwrap();
14237 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
14238 if let Some(name) = engine.first_installed_local_model(false) {
14239 let runnable = engine
14240 .unified_registry
14241 .find_by_name(&name)
14242 .map(|s| !s.is_foundation_models() || s.available)
14243 .unwrap_or(false);
14244 assert!(
14245 runnable,
14246 "last-resort fallback returned a non-runnable model: {name}"
14247 );
14248 }
14249 }
14250
14251 #[test]
14252 fn empty_tool_catalog_is_no_tools() {
14253 let mut req = GenerateRequest {
14260 prompt: "p".into(),
14261 ..Default::default()
14262 };
14263 assert!(!InferenceEngine::request_has_tools(&req));
14264 req.tools = Some(vec![]);
14265 assert!(!InferenceEngine::request_has_tools(&req));
14266 req.tools = Some(vec![serde_json::json!({
14267 "name": "t", "description": "d", "parameters": {"type": "object"}
14268 })]);
14269 assert!(InferenceEngine::request_has_tools(&req));
14270 }
14271
14272 #[test]
14273 fn top_k_keeps_only_k_highest() {
14274 let mut probs = vec![0.1, 0.4, 0.2, 0.25, 0.05];
14276 InferenceEngine::apply_top_k_top_p(&mut probs, 2, 1.0);
14277 assert!(probs[0] == 0.0 && probs[2] == 0.0 && probs[4] == 0.0);
14279 assert!(probs[1] > 0.0 && probs[3] > 0.0);
14280 let sum: f32 = probs.iter().sum();
14281 assert!((sum - 1.0).abs() < 1e-5, "renormalized to 1.0, got {sum}");
14282 }
14283
14284 #[test]
14285 fn top_k_zero_is_a_noop() {
14286 let mut probs = vec![0.1, 0.4, 0.2, 0.3];
14287 let before = probs.clone();
14288 InferenceEngine::apply_top_k_top_p(&mut probs, 0, 1.0);
14289 assert_eq!(probs, before);
14290 }
14291
14292 #[test]
14293 fn top_p_nucleus_truncates_tail() {
14294 let mut probs = vec![0.6, 0.3, 0.07, 0.03];
14295 InferenceEngine::apply_top_k_top_p(&mut probs, 0, 0.9);
14296 assert!(probs[2] == 0.0 && probs[3] == 0.0);
14298 assert!(probs[0] > 0.0 && probs[1] > 0.0);
14299 }
14300
14301 #[test]
14302 fn truncate_at_stop_excludes_stop_sequence() {
14303 let stops = vec!["<|end|>".to_string(), "STOP".to_string()];
14304 assert_eq!(
14305 tasks::generate::truncate_at_stop("hello world<|end|>extra", &stops),
14306 "hello world"
14307 );
14308 assert_eq!(
14310 tasks::generate::truncate_at_stop("aSTOPb<|end|>c", &stops),
14311 "a"
14312 );
14313 assert_eq!(
14315 tasks::generate::truncate_at_stop("clean output", &stops),
14316 "clean output"
14317 );
14318 assert_eq!(
14320 tasks::generate::truncate_at_stop("text", &["".to_string()]),
14321 "text"
14322 );
14323 }
14324
14325 #[test]
14326 fn no_backend_hint_fires_on_missing_backend_phrases() {
14327 for phrase in [
14329 "no credential for proprietary provider 'parslee'",
14330 "model not found",
14331 "no models available",
14332 "model declares ModelSource::Delegated but no inference runner is registered",
14333 ] {
14334 let hint = no_backend_recovery_hint(phrase)
14335 .unwrap_or_else(|| panic!("expected a hint for {phrase:?}"));
14336 assert!(hint.contains("car models pull"));
14337 assert!(hint.contains("car auth login"));
14340 assert!(hint.contains(phrase));
14342 }
14343 }
14344
14345 #[test]
14346 fn no_backend_hint_opens_with_the_exported_marker() {
14347 let hint = no_backend_recovery_hint(
14351 "model declares ModelSource::Delegated but no inference runner is registered",
14352 )
14353 .expect("a delegated model with no runner is a no-backend case");
14354 assert!(
14355 hint.starts_with(NO_BACKEND_RECOVERY_MARKER),
14356 "hint must open with the exported marker: {hint}"
14357 );
14358 assert!(hint.contains(NO_BACKEND_RECOVERY_MARKER), "{hint}");
14359 }
14360
14361 #[test]
14362 fn route_failure_context_keeps_the_no_backend_marker_behind_the_credential_summary() {
14363 let credential = parslee_signed_out_route_failure();
14367 let underlying = InferenceError::InferenceFailed(
14368 "model declares ModelSource::Delegated but no inference runner is registered".into(),
14369 );
14370 let augmented = apply_route_failure_context(underlying, Some(&credential));
14371 let InferenceError::InferenceFailed(message) = &augmented else {
14372 panic!("a no-backend exhaustion stays InferenceFailed: {augmented:?}");
14373 };
14374 assert!(
14375 message.starts_with(&credential.summary),
14376 "credential context is prepended: {message}"
14377 );
14378 assert!(
14379 message.contains(NO_BACKEND_RECOVERY_MARKER),
14380 "the marker survives the prepend: {message}"
14381 );
14382 assert!(
14383 !message.starts_with(NO_BACKEND_RECOVERY_MARKER),
14384 "the marker is mid-message here, which is why consumers use contains: {message}"
14385 );
14386 }
14387
14388 #[test]
14396 fn credential_failure_is_matchable_as_data() {
14397 let expired = InferenceError::CredentialUnavailable {
14398 provider: "parslee".into(),
14399 model: "parslee/reasoning".into(),
14400 reason: CredentialFailure::Expired {
14401 expires_at: 1_754_257_929,
14402 },
14403 detail: "the Parslee token expired at unix 1754257929 and could not be refreshed"
14404 .into(),
14405 };
14406 let InferenceError::CredentialUnavailable { reason, .. } = &expired else {
14407 panic!("expected CredentialUnavailable");
14408 };
14409 assert_eq!(
14410 *reason,
14411 CredentialFailure::Expired {
14412 expires_at: 1_754_257_929
14413 },
14414 "a consumer must be able to branch on the expiry without parsing prose"
14415 );
14416 assert_ne!(
14420 CredentialFailure::SignedOut,
14421 CredentialFailure::StoreUnreadable
14422 );
14423 assert_ne!(
14424 CredentialFailure::SignedOut,
14425 CredentialFailure::Expired { expires_at: 0 }
14426 );
14427 assert_ne!(
14428 CredentialFailure::StoreUnreadable,
14429 CredentialFailure::RaceRetryable
14430 );
14431 }
14432
14433 #[test]
14443 fn typed_credential_error_still_satisfies_the_substring_classifiers() {
14444 for reason in [
14445 CredentialFailure::Expired { expires_at: 1 },
14446 CredentialFailure::SignedOut,
14447 CredentialFailure::StoreUnreadable,
14448 CredentialFailure::RaceRetryable,
14449 CredentialFailure::EnvVarMissing {
14450 env_var: "OPENAI_API_KEY".into(),
14451 },
14452 ] {
14453 let rendered = InferenceError::CredentialUnavailable {
14454 provider: "parslee".into(),
14455 model: "parslee/reasoning".into(),
14456 reason: reason.clone(),
14457 detail: "detail text".into(),
14458 }
14459 .to_string();
14460 assert!(
14462 rendered
14463 .to_ascii_lowercase()
14464 .contains("no credential for proprietary"),
14465 "classifier substring lost for {reason:?}: {rendered}"
14466 );
14467 assert!(rendered.contains("parslee/reasoning"), "{rendered}");
14469 assert!(rendered.contains("detail text"), "{rendered}");
14471 }
14472 }
14473
14474 #[test]
14475 fn auth_expired_hint_fires_on_auth_rejection_but_not_transient() {
14476 for phrase in [
14478 "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
14479 "HTTP 403: forbidden",
14480 "invalid_grant: The refresh token is invalid or expired",
14481 "token expired",
14482 ] {
14483 let hint = auth_expired_recovery_hint(phrase)
14484 .unwrap_or_else(|| panic!("expected an auth hint for {phrase:?}"));
14485 assert!(hint.contains("car auth login"));
14486 assert!(hint.contains(phrase));
14487 }
14488 assert!(auth_expired_recovery_hint("API returned 503: service unavailable").is_none());
14490 assert!(auth_expired_recovery_hint("request timed out").is_none());
14491 }
14492
14493 #[test]
14511 fn auth_expired_hint_never_carries_the_no_backend_marker() {
14512 for phrase in [
14515 "Parslee org lookup failed: the account service could not be reached",
14516 "Authentication required",
14517 "HTTP 401 Unauthorized",
14518 "HTTP 403 Forbidden",
14519 "invalid_grant: the refresh token is invalid or expired",
14520 "token expired",
14521 ] {
14522 let hint = auth_expired_recovery_hint(phrase)
14523 .unwrap_or_else(|| panic!("expected an auth hint for {phrase:?}"));
14524 assert!(
14525 !hint.contains(NO_BACKEND_RECOVERY_MARKER),
14526 "the auth-expired hint now carries the no-backend marker, so \
14527 car infer would auto-pull for {phrase:?}: {hint}"
14528 );
14529 assert!(hint.contains("car models pull"), "{hint}");
14533 }
14534 assert!(auth_expired_recovery_hint("API returned 503: service unavailable").is_none());
14537 }
14538
14539 #[test]
14544 fn auth_rejection_classifier_matches_the_real_expired_token_error() {
14545 assert!(is_auth_rejection_message(
14546 "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
14547 Authentication required"
14548 ));
14549 for transient in [
14552 "API returned 503: service unavailable",
14553 "request timed out",
14554 "connection reset by peer",
14555 ] {
14556 assert!(
14557 !is_auth_rejection_message(transient),
14558 "transient wrongly classified as an auth rejection: {transient:?}"
14559 );
14560 }
14561 }
14562
14563 #[test]
14571 fn typed_errors_classify_by_their_structure_not_their_prose() {
14572 use FallbackReason as R;
14573 let cases: Vec<(InferenceError, R)> = vec![
14574 (
14577 InferenceError::ProviderAccount {
14578 provider: "openai".into(),
14579 status: 401,
14580 message: "provider rejected the API key — check the configured credential"
14581 .into(),
14582 },
14583 R::CredentialRejected,
14584 ),
14585 (
14587 InferenceError::ProviderAccount {
14588 provider: "openrouter".into(),
14589 status: 402,
14590 message: "OpenRouter account is out of credits".into(),
14591 },
14592 R::QuotaExhausted,
14594 ),
14595 (
14596 InferenceError::CredentialUnavailable {
14597 provider: "parslee".into(),
14598 model: "parslee/reasoning".into(),
14599 reason: CredentialFailure::Expired { expires_at: 0 },
14600 detail: "session expired".into(),
14601 },
14602 R::CredentialRejected,
14603 ),
14604 (
14605 InferenceError::CredentialUnavailable {
14606 provider: "openai".into(),
14607 model: "openai/gpt-5.6".into(),
14608 reason: CredentialFailure::EnvVarMissing {
14609 env_var: "OPENAI_API_KEY".into(),
14610 },
14611 detail: "not set".into(),
14612 },
14613 R::CredentialAbsent,
14614 ),
14615 (
14618 InferenceError::CredentialUnavailable {
14619 provider: "parslee".into(),
14620 model: "parslee/reasoning".into(),
14621 reason: CredentialFailure::StoreUnreadable,
14622 detail: "the bounded Keychain helper timed out".into(),
14623 },
14624 R::Failed,
14625 ),
14626 (
14627 InferenceError::Transient {
14628 status: Some(429),
14629 message: "slow down".into(),
14630 },
14631 R::RateLimited,
14632 ),
14633 (
14638 InferenceError::Transient {
14639 status: None,
14640 message: "connection reset by peer".into(),
14641 },
14642 R::Failed,
14643 ),
14644 (
14645 InferenceError::Transient {
14646 status: Some(503),
14647 message: "upstream down".into(),
14648 },
14649 R::Failed,
14650 ),
14651 ];
14652 for (err, want) in cases {
14653 assert_eq!(classify_fallback_reason(&err), want, "{err}");
14654 }
14655 }
14656
14657 #[test]
14665 fn a_quoted_status_in_a_provider_body_does_not_decide_the_bucket() {
14666 use FallbackReason as R;
14667 for (msg, want) in [
14668 (
14669 "API returned 400 Bad Request: your last request 401'd upstream and was unauthorized",
14670 R::Failed,
14671 ),
14672 ("API returned 400 Bad Request: timeout param invalid", R::Failed),
14673 ("API returned 429 Too Many Requests: slow down", R::RateLimited),
14674 ("API returned 401 Unauthorized: bad key", R::CredentialRejected),
14675 ] {
14676 assert_eq!(
14677 classify_fallback_reason(&InferenceError::InferenceFailed(msg.into())),
14678 want,
14679 "{msg}"
14680 );
14681 }
14682 }
14683
14684 #[test]
14688 fn a_parslee_org_lookup_failure_is_classified_by_its_status() {
14689 use FallbackReason as R;
14690 for (msg, want) in [
14691 (
14692 "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
14693 R::CredentialRejected,
14694 ),
14695 (
14696 "Parslee org lookup failed: HTTP 429 Too Many Requests: slow down",
14697 R::RateLimited,
14698 ),
14699 (
14700 "Parslee org lookup failed: HTTP 500 Internal Server Error: boom",
14701 R::Failed,
14702 ),
14703 ] {
14704 assert_eq!(
14705 classify_fallback_reason(&InferenceError::InferenceFailed(msg.into())),
14706 want,
14707 "{msg}"
14708 );
14709 }
14710 }
14711
14712 #[test]
14716 fn every_skipped_lane_is_recorded_in_order() {
14717 let mut hops = Vec::new();
14718 for (cand, err) in [
14719 (
14720 "lane-one",
14721 InferenceError::Transient {
14722 status: Some(429),
14723 message: "x".into(),
14724 },
14725 ),
14726 (
14729 "lane-two",
14730 InferenceError::Transient {
14731 status: None,
14732 message: "connection refused".into(),
14733 },
14734 ),
14735 (
14736 "lane-three",
14737 InferenceError::ProviderAccount {
14738 provider: "openai".into(),
14739 status: 401,
14740 message: "rejected".into(),
14741 },
14742 ),
14743 ] {
14744 record_fallback_from(&mut hops, cand, &err);
14745 }
14746 assert_eq!(
14747 hops.iter()
14748 .map(|h| h.candidate.as_str())
14749 .collect::<Vec<_>>(),
14750 ["lane-one", "lane-two", "lane-three"]
14751 );
14752 assert_eq!(
14753 hops.iter().map(|h| h.reason).collect::<Vec<_>>(),
14754 [
14755 FallbackReason::RateLimited,
14756 FallbackReason::Failed,
14757 FallbackReason::CredentialRejected
14758 ]
14759 );
14760 }
14761
14762 #[test]
14770 fn a_refused_api_key_is_journaled_but_does_not_claim_sign_in_fixes_it() {
14771 let refused = InferenceError::ProviderAccount {
14772 provider: "openai".into(),
14773 status: 401,
14774 message: "provider rejected the API key — check the configured credential".into(),
14775 };
14776 let mut hops = Vec::new();
14777 record_fallback_from(&mut hops, "openai/gpt-5.6", &refused);
14778 assert_eq!(hops[0].reason, FallbackReason::CredentialRejected);
14779
14780 let mut auth = None;
14782 record_auth_dead_lane(&mut auth, "openai/gpt-5.6", &refused.to_string());
14783 assert_eq!(auth, None, "car auth login does not fix a bad OpenAI key");
14784 }
14785
14786 #[test]
14787 fn auth_dead_lane_records_first_rejected_candidate_only() {
14788 let mut slot: Option<String> = None;
14790 record_auth_dead_lane(
14791 &mut slot,
14792 "parslee/reasoning",
14793 "API returned 503: unavailable",
14794 );
14795 record_auth_dead_lane(&mut slot, "openai/gpt-5.6", "request timed out");
14796 assert_eq!(slot, None);
14797
14798 record_auth_dead_lane(
14800 &mut slot,
14801 "parslee/reasoning",
14802 "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
14803 );
14804 assert_eq!(slot.as_deref(), Some("parslee/reasoning"));
14805
14806 record_auth_dead_lane(&mut slot, "anthropic/claude", "HTTP 403: forbidden");
14809 assert_eq!(slot.as_deref(), Some("parslee/reasoning"));
14810 }
14811
14812 #[test]
14813 fn configured_provider_with_expired_token_is_named_before_a_local_oom() {
14814 let expired = InferenceError::CredentialUnavailable {
14815 provider: "parslee".into(),
14816 model: "parslee/reasoning".into(),
14817 reason: CredentialFailure::Expired { expires_at: 42 },
14818 detail: "access token expired".into(),
14819 };
14820 let mut credential = None;
14821 record_route_credential_failure(&mut credential, "parslee/reasoning", &expired, false);
14822
14823 let error = apply_route_failure_context(
14824 InferenceError::InferenceFailed(
14825 "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
14826 .into(),
14827 ),
14828 credential.as_ref(),
14829 )
14830 .to_string();
14831
14832 let auth_pos = error
14833 .find("Parslee login expired")
14834 .expect("expired login must be named");
14835 let remedy_pos = error
14836 .find("car auth login")
14837 .expect("credential remedy must be named");
14838 let oom_pos = error
14839 .find("9059 MB")
14840 .expect("fallback error must remain as secondary detail");
14841 assert!(auth_pos < remedy_pos && remedy_pos < oom_pos, "{error}");
14842 assert!(is_auth_failure_message(&error), "{error}");
14843 }
14844
14845 #[test]
14846 fn absent_login_is_named_before_a_local_oom() {
14847 let failure = parslee_signed_out_route_failure();
14848 let error = apply_route_failure_context(
14849 InferenceError::InferenceFailed("local fallback needs 9059 MB".into()),
14850 Some(&failure),
14851 )
14852 .to_string();
14853
14854 let absent_pos = error
14855 .find("Parslee login is absent")
14856 .expect("missing login must be named");
14857 let remedy_pos = error
14858 .find("car auth login")
14859 .expect("credential remedy must be named");
14860 let oom_pos = error
14861 .find("9059 MB")
14862 .expect("fallback OOM must remain as secondary detail");
14863 assert!(absent_pos < remedy_pos && remedy_pos < oom_pos, "{error}");
14864 }
14865
14866 #[test]
14867 fn genuine_local_oom_is_not_reclassified_as_auth() {
14868 let oom = InferenceError::InferenceFailed(
14869 "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
14870 .into(),
14871 );
14872 let error = apply_route_failure_context(oom, None);
14873 assert!(matches!(error, InferenceError::InferenceFailed(ref message)
14874 if message.starts_with("This model needs about 9059 MB")));
14875 }
14876
14877 #[test]
14878 fn unconfigured_provider_is_not_surfaced_over_the_terminal_failure() {
14879 let mut credential = None;
14880 record_route_credential_failure(
14881 &mut credential,
14882 "openai/gpt-5.6",
14883 &InferenceError::CredentialUnavailable {
14884 provider: "openai".into(),
14885 model: "openai/gpt-5.6".into(),
14886 reason: CredentialFailure::EnvVarMissing {
14887 env_var: "OPENAI_API_KEY".into(),
14888 },
14889 detail: "set OPENAI_API_KEY".into(),
14890 },
14891 false,
14892 );
14893
14894 let terminal = InferenceError::InferenceFailed(
14895 "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
14896 .into(),
14897 );
14898 let error = apply_route_failure_context(terminal, credential.as_ref()).to_string();
14899 assert!(
14900 credential.is_none(),
14901 "an unconfigured fallback is ambient noise"
14902 );
14903 assert!(error.contains("9059 MB"), "{error}");
14904 assert!(!error.contains("OPENAI_API_KEY"), "{error}");
14905 assert!(!is_auth_failure_message(&error), "{error}");
14906 }
14907
14908 fn workspace_required() -> InferenceError {
14912 InferenceError::WorkspaceRequired {
14913 provider: "Parslee".into(),
14914 detail: "finish setting up at https://parslee.ai, then try again".into(),
14915 }
14916 }
14917
14918 #[test]
14927 fn workspace_required_reads_as_configuration_not_sign_in() {
14928 let rendered = workspace_required().to_string();
14929 for marker in AUTH_FAILURE_MESSAGE_MARKERS {
14930 assert!(
14931 !rendered
14932 .to_ascii_lowercase()
14933 .contains(&marker.to_ascii_lowercase()),
14934 "`{marker}` must not appear in the no-workspace text: {rendered}"
14935 );
14936 }
14937 assert!(!is_auth_failure_message(&rendered), "{rendered}");
14938 assert!(!is_auth_rejection_message(&rendered), "{rendered}");
14939 assert!(rendered.contains("https://parslee.ai"), "{rendered}");
14940 }
14941
14942 #[test]
14949 fn workspace_required_survives_a_mixed_chain() {
14950 let mut credential = None;
14951 record_route_credential_failure(
14952 &mut credential,
14953 "openai/gpt-5.6",
14954 &InferenceError::CredentialUnavailable {
14955 provider: "openai".into(),
14956 model: "openai/gpt-5.6".into(),
14957 reason: CredentialFailure::SignedOut,
14958 detail: "no account is signed in".into(),
14959 },
14960 true,
14961 );
14962 assert!(
14963 credential.is_some(),
14964 "the fixture must actually record a credential failure"
14965 );
14966
14967 let final_error = apply_route_failure_context(workspace_required(), credential.as_ref());
14968 assert!(
14969 matches!(final_error, InferenceError::WorkspaceRequired { .. }),
14970 "the type must survive the route context: {final_error}"
14971 );
14972 assert!(!is_auth_failure_message(&final_error.to_string()));
14973 assert!(
14974 error_ends_fallback_chain(&final_error),
14975 "no later candidate on that account can succeed"
14976 );
14977 }
14978
14979 #[test]
14986 fn repeated_workspace_required_leaves_model_health_untouched() {
14987 assert!(
14988 !error_counts_against_circuit_breaker(&workspace_required()),
14989 "the breaker must not count a workspace gap"
14990 );
14991
14992 let mut tracker = OutcomeTracker::new();
14993 for _ in 0..5 {
14994 let trace = tracker.record_start("parslee/advisor", InferenceTask::Generate, "test");
14995 record_dispatch_failure(&mut tracker, &trace, &workspace_required());
14996 }
14997 let failed = tracker
14998 .profile("parslee/advisor")
14999 .map(|p| p.fail_count)
15000 .unwrap_or(0);
15001 assert_eq!(
15002 failed, 0,
15003 "five workspace refusals must not bench the model"
15004 );
15005
15006 let trace = tracker.record_start("parslee/advisor", InferenceTask::Generate, "test");
15007 record_dispatch_failure(
15008 &mut tracker,
15009 &trace,
15010 &InferenceError::InferenceFailed("decoder fell over".into()),
15011 );
15012 assert_eq!(
15013 tracker
15014 .profile("parslee/advisor")
15015 .map(|p| p.fail_count)
15016 .unwrap_or(0),
15017 1,
15018 "a genuine failure must still count, or the test above proves nothing"
15019 );
15020 }
15021
15022 #[test]
15023 fn explicitly_requested_missing_credential_uses_the_shared_auth_table() {
15024 let mut credential = None;
15025 record_route_credential_failure(
15026 &mut credential,
15027 "openai/gpt-5.6",
15028 &InferenceError::CredentialUnavailable {
15029 provider: "openai".into(),
15030 model: "openai/gpt-5.6".into(),
15031 reason: CredentialFailure::EnvVarMissing {
15032 env_var: "OPENAI_API_KEY".into(),
15033 },
15034 detail: "set OPENAI_API_KEY".into(),
15035 },
15036 true,
15037 );
15038 let summary = credential
15039 .expect("an explicitly requested provider must surface its missing key")
15040 .summary;
15041 assert!(summary.contains(AUTH_ENV_MISSING_MARKER), "{summary}");
15042 assert!(is_auth_failure_message(&summary), "{summary}");
15043 }
15044
15045 #[test]
15046 fn latest_actionable_credential_failure_wins() {
15047 let mut credential = None;
15048 record_route_credential_failure(
15049 &mut credential,
15050 "parslee/reasoning",
15051 &InferenceError::ProviderAccount {
15052 provider: "parslee".into(),
15053 status: 401,
15054 message: "Unauthorized".into(),
15055 },
15056 false,
15057 );
15058 record_route_credential_failure(
15059 &mut credential,
15060 "anthropic/claude",
15061 &InferenceError::CredentialUnavailable {
15062 provider: "anthropic".into(),
15063 model: "anthropic/claude".into(),
15064 reason: CredentialFailure::Expired { expires_at: 43 },
15065 detail: "configured token expired".into(),
15066 },
15067 false,
15068 );
15069
15070 assert_eq!(
15071 credential
15072 .expect("latest actionable credential cause must be retained")
15073 .summary,
15074 "anthropic login expired for `anthropic/claude` — run `car auth login`"
15075 );
15076 }
15077
15078 #[test]
15079 fn store_unreadable_summary_uses_the_shared_auth_table() {
15080 let summary = route_credential_failure(
15081 "parslee/reasoning",
15082 &InferenceError::CredentialUnavailable {
15083 provider: "parslee".into(),
15084 model: "parslee/reasoning".into(),
15085 reason: CredentialFailure::StoreUnreadable,
15086 detail: "keychain helper timed out".into(),
15087 },
15088 false,
15089 )
15090 .expect("an unreadable configured credential store is actionable");
15091 assert!(summary.contains(AUTH_STORE_UNREADABLE_MARKER), "{summary}");
15092 assert!(is_auth_failure_message(&summary), "{summary}");
15093 }
15094
15095 fn chain_gate_fixture_schema(id: &str, provider: &str, source: ModelSource) -> ModelSchema {
15096 ModelSchema {
15097 id: id.into(),
15098 name: id.into(),
15099 provider: provider.into(),
15100 family: "test".into(),
15101 version: "1".into(),
15102 capabilities: vec![ModelCapability::Generate],
15103 context_length: 32_768,
15104 max_output_tokens: Some(4_096),
15105 param_count: String::new(),
15106 quantization: None,
15107 performance: Default::default(),
15108 cost: Default::default(),
15109 source,
15110 tags: vec![],
15111 supported_params: vec![],
15112 public_benchmarks: vec![],
15113 trust_tier: TrustTier::Community,
15114 deprecated: false,
15115 available: true,
15116 weights_ready: true,
15117 }
15118 }
15119
15120 #[test]
15121 fn signed_out_pre_seed_is_gated_on_a_parslee_route_in_the_chain() {
15122 let local = chain_gate_fixture_schema(
15123 "qwen/qwen3-4b:q4_k_m",
15124 "qwen",
15125 ModelSource::Mlx {
15126 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
15127 hf_weight_file: None,
15128 },
15129 );
15130 let ollama = chain_gate_fixture_schema(
15131 "ollama/llama3",
15132 "ollama",
15133 ModelSource::Ollama {
15134 model_tag: "llama3".into(),
15135 host: "http://localhost:11434".into(),
15136 },
15137 );
15138 let parslee = chain_gate_fixture_schema(
15139 "parslee/reasoning",
15140 "parslee",
15141 ModelSource::Proprietary {
15142 provider: "parslee".into(),
15143 endpoint: "https://api.parslee.ai".into(),
15144 auth: ProprietaryAuth::OAuth2Pkce {
15145 authority: "https://login.example".into(),
15146 client_id: "client".into(),
15147 scopes: vec![],
15148 },
15149 protocol: Default::default(),
15150 },
15151 );
15152 let cloud = chain_gate_fixture_schema(
15153 "openai/gpt-5.6",
15154 "openai",
15155 ModelSource::RemoteApi {
15156 endpoint: "https://api.openai.com".into(),
15157 api_key_env: "OPENAI_API_KEY".into(),
15158 api_key_envs: vec![],
15159 api_version: None,
15160 protocol: schema::ApiProtocol::OpenAiCompat,
15161 },
15162 );
15163 let schemas: std::collections::HashMap<&str, &ModelSchema> = [
15164 ("qwen/qwen3-4b:q4_k_m", &local),
15165 ("ollama/llama3", &ollama),
15166 ("parslee/reasoning", &parslee),
15167 ("openai/gpt-5.6", &cloud),
15168 ]
15169 .into_iter()
15170 .collect();
15171 let resolve = |m: &str| schemas.get(m).copied();
15172
15173 let local_only = vec![
15175 "qwen/qwen3-4b:q4_k_m".to_string(),
15176 "ollama/llama3".to_string(),
15177 ];
15178 assert!(!chain_includes_parslee_route(resolve, &local_only));
15179
15180 let with_parslee = vec![
15182 "parslee/reasoning".to_string(),
15183 "qwen/qwen3-4b:q4_k_m".to_string(),
15184 ];
15185 assert!(chain_includes_parslee_route(resolve, &with_parslee));
15186 let with_cloud = vec![
15187 "openai/gpt-5.6".to_string(),
15188 "qwen/qwen3-4b:q4_k_m".to_string(),
15189 ];
15190 assert!(!chain_includes_parslee_route(resolve, &with_cloud));
15194 let mut credential = chain_includes_parslee_route(resolve, &with_cloud)
15195 .then(parslee_signed_out_route_failure);
15196 let outage = InferenceError::Transient {
15197 status: Some(500),
15198 message: "OpenAI HTTP 500 Internal Server Error".into(),
15199 };
15200 record_route_credential_failure(&mut credential, &with_cloud[0], &outage, false);
15201 let terminal = apply_route_failure_context(outage, credential.as_ref());
15202 assert!(matches!(terminal, InferenceError::Transient { .. }));
15203 assert!(!is_auth_failure_message(&terminal.to_string()));
15204 let oom = InferenceError::InferenceFailed("local model out of memory".into());
15205 record_route_credential_failure(&mut credential, &with_cloud[1], &oom, false);
15206 let terminal = apply_route_failure_context(oom, credential.as_ref()).to_string();
15207 assert!(!terminal.contains("Parslee"), "{terminal}");
15208 assert!(!is_auth_failure_message(&terminal), "{terminal}");
15209
15210 record_route_credential_failure(
15212 &mut credential,
15213 &with_cloud[0],
15214 &InferenceError::ProviderAccount {
15215 provider: "openai".into(),
15216 status: 401,
15217 message: "invalid API key".into(),
15218 },
15219 false,
15220 );
15221 let terminal = apply_route_failure_context(
15222 InferenceError::InferenceFailed("local model out of memory".into()),
15223 credential.as_ref(),
15224 )
15225 .to_string();
15226 assert!(terminal.contains("openai"), "{terminal}");
15227 assert!(!terminal.contains("Parslee"), "{terminal}");
15228 assert!(is_auth_failure_message(&terminal), "{terminal}");
15229
15230 let unknown = vec!["missing/model".to_string()];
15232 assert!(!chain_includes_parslee_route(resolve, &unknown));
15233 }
15234
15235 #[test]
15239 fn local_only_terminal_failures_are_not_relabeled_as_auth() {
15240 let cases = [
15241 InferenceError::InferenceFailed(
15243 "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
15244 .into(),
15245 ),
15246 InferenceError::Transient {
15248 status: Some(500),
15249 message: "HTTP 500 Internal Server Error".into(),
15250 },
15251 InferenceError::InferenceFailed("llama runner process has terminated".into()),
15253 ];
15254 for case in cases {
15255 let error = apply_route_failure_context(case, None).to_string();
15256 assert!(!error.contains("Parslee login is absent"), "{error}");
15257 assert!(!is_auth_failure_message(&error), "{error}");
15258 }
15259
15260 let error = apply_route_failure_context(
15264 InferenceError::ModelNotFound("qwen/qwen3-4b:q4_k_m".into()),
15265 None,
15266 )
15267 .to_string();
15268 assert!(!error.contains("Parslee login is absent"), "{error}");
15269 assert!(error.contains("car models pull"), "{error}");
15270 }
15271
15272 #[test]
15275 fn credential_context_preserves_the_typed_terminal_variant() {
15276 let credential = parslee_signed_out_route_failure();
15277
15278 let transient = apply_route_failure_context(
15279 InferenceError::Transient {
15280 status: Some(500),
15281 message: "HTTP 500 Internal Server Error".into(),
15282 },
15283 Some(&credential),
15284 );
15285 match &transient {
15286 InferenceError::Transient { status, message } => {
15287 assert_eq!(*status, Some(500));
15288 assert!(message.starts_with("Parslee login is absent"), "{message}");
15289 assert!(message.contains("HTTP 500"), "{message}");
15290 }
15291 other => panic!("Transient must stay Transient, got {other:?}"),
15292 }
15293
15294 let account = apply_route_failure_context(
15295 InferenceError::ProviderAccount {
15296 provider: "openai".into(),
15297 status: 402,
15298 message: "insufficient credits".into(),
15299 },
15300 Some(&credential),
15301 );
15302 assert!(
15303 matches!(
15304 &account,
15305 InferenceError::ProviderAccount {
15306 provider,
15307 status: 402,
15308 ..
15309 } if provider == "openai"
15310 ),
15311 "ProviderAccount must stay ProviderAccount, got {account:?}"
15312 );
15313
15314 let unavailable = apply_route_failure_context(
15315 InferenceError::CredentialUnavailable {
15316 provider: "parslee".into(),
15317 model: "parslee/reasoning".into(),
15318 reason: CredentialFailure::Expired { expires_at: 42 },
15319 detail: "access token expired".into(),
15320 },
15321 Some(&credential),
15322 );
15323 match &unavailable {
15324 InferenceError::CredentialUnavailable { reason, detail, .. } => {
15325 assert_eq!(*reason, CredentialFailure::Expired { expires_at: 42 });
15326 assert!(detail.contains("Parslee login is absent"), "{detail}");
15327 }
15328 other => panic!("CredentialUnavailable must keep its reason data, got {other:?}"),
15329 }
15330 assert!(is_auth_failure_message(&unavailable.to_string()));
15331 }
15332
15333 #[test]
15336 fn non_parslee_rejection_summaries_match_the_shared_classifier() {
15337 let rejected = route_credential_failure(
15338 "openai/gpt-5.6",
15339 &InferenceError::ProviderAccount {
15340 provider: "openai".into(),
15341 status: 403,
15342 message: "key revoked".into(),
15343 },
15344 false,
15345 )
15346 .expect("a 403 from a configured provider is actionable");
15347 assert!(rejected.contains("credential was rejected"), "{rejected}");
15348 assert!(is_auth_failure_message(&rejected), "{rejected}");
15349
15350 let generic = route_credential_failure(
15351 "openai/gpt-5.6",
15352 &InferenceError::InferenceFailed("upstream said: token expired".into()),
15353 false,
15354 )
15355 .expect("an auth-rejection message from a non-Parslee route is actionable");
15356 assert!(generic.contains("repair its provider login"), "{generic}");
15357 assert!(is_auth_failure_message(&generic), "{generic}");
15358 }
15359
15360 #[test]
15365 fn the_recovery_hints_lead_with_parslee_sign_in() {
15366 for hint in [
15367 no_backend_recovery_hint("no credential for proprietary provider 'parslee'")
15368 .expect("a missing credential is a no-backend case"),
15369 auth_expired_recovery_hint(
15370 "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
15371 )
15372 .expect("a rejected session is an auth case"),
15373 ] {
15374 let login = hint
15375 .find("car auth login")
15376 .expect("sign-in must be offered");
15377 let pull = hint
15378 .find("car models pull")
15379 .expect("the local path must still be offered");
15380 assert!(login < pull, "sign-in must be read first:\n{hint}");
15381 }
15382 assert!(
15385 no_backend_recovery_hint("no credential for proprietary provider 'parslee'")
15386 .expect("hint")
15387 .starts_with(NO_BACKEND_RECOVERY_MARKER),
15388 "the marker opening is load-bearing"
15389 );
15390 }
15391
15392 #[test]
15395 fn fresh_install_exhaustion_keeps_the_models_pull_guidance() {
15396 let credential = parslee_signed_out_route_failure();
15397 let error = apply_route_failure_context(
15398 InferenceError::InferenceFailed("no models available for generate".into()),
15399 Some(&credential),
15400 )
15401 .to_string();
15402 assert!(error.contains("Parslee login is absent"), "{error}");
15403 assert!(
15404 error.contains("car models pull qwen/qwen3-4b:q4_k_m"),
15405 "{error}"
15406 );
15407 assert!(error.contains("car auth login"), "{error}");
15408 }
15409
15410 #[test]
15413 fn auth_fallback_from_round_trips_and_defaults_to_none() {
15414 let mut result: InferenceResult = serde_json::from_value(serde_json::json!({
15415 "text": "hi",
15416 "tool_calls": [],
15417 "trace_id": "t",
15418 "model_used": "openai/gpt-5.6",
15419 "latency_ms": 1,
15420 }))
15421 .expect("a payload without the field still deserializes");
15422 assert_eq!(result.auth_fallback_from, None);
15423 let json = serde_json::to_value(&result).unwrap();
15424 assert!(json.get("auth_fallback_from").is_none());
15425
15426 result.auth_fallback_from = Some("parslee/reasoning".to_string());
15427 let json = serde_json::to_value(&result).unwrap();
15428 assert_eq!(json["auth_fallback_from"], "parslee/reasoning");
15429 let back: InferenceResult = serde_json::from_value(json).unwrap();
15430 assert_eq!(
15431 back.auth_fallback_from.as_deref(),
15432 Some("parslee/reasoning")
15433 );
15434 }
15435
15436 #[test]
15441 fn local_last_resort_turn_carries_model_id_and_flag() {
15442 let ordinary: InferenceResult = serde_json::from_value(serde_json::json!({
15443 "text": "hi",
15444 "tool_calls": [],
15445 "trace_id": "ordinary",
15446 "model_used": "anthropic/claude-haiku-4-5:latest",
15447 "latency_ms": 1,
15448 }))
15449 .expect("older payloads default the marker");
15450 assert!(!ordinary.local_last_resort);
15451
15452 let fallback: InferenceResult = serde_json::from_value(serde_json::json!({
15453 "text": "offline answer",
15454 "tool_calls": [],
15455 "trace_id": "fallback",
15456 "model_used": "Qwen3 4B MLX",
15457 "resolved_model_id": "mlx/qwen3-4b:4bit",
15458 "latency_ms": 1,
15459 "local_last_resort": true,
15460 }))
15461 .expect("fallback attribution payload");
15462 assert_eq!(fallback.served_model_id(), "mlx/qwen3-4b:4bit");
15463 assert!(fallback.local_last_resort);
15464
15465 assert!(!is_local_last_resort(None, "mlx/qwen3-4b:4bit"));
15466 assert!(report_local_last_resort_served(
15467 Some("mlx/qwen3-4b:4bit"),
15468 "mlx/qwen3-4b:4bit",
15469 "mlx/qwen3-4b:4bit"
15470 ));
15471 assert!(!is_local_last_resort(
15472 Some("mlx/qwen3-4b:4bit"),
15473 "anthropic/claude-haiku-4-5:latest"
15474 ));
15475 }
15476
15477 #[test]
15478 fn no_backend_hint_passes_through_transient_errors() {
15479 for phrase in [
15482 "API returned 401 Unauthorized",
15483 "API returned 429 Too Many Requests",
15484 "API returned 500 Internal Server Error",
15485 "connection refused",
15486 "request timed out",
15487 "parse response: unexpected end of input",
15488 ] {
15489 assert!(
15490 no_backend_recovery_hint(phrase).is_none(),
15491 "transient error wrongly classified as no-backend: {phrase:?}"
15492 );
15493 }
15494 }
15495
15496 static ENV_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
15499
15500 #[cfg(unix)]
15501 #[tokio::test]
15502 async fn exact_codex_subscription_row_dispatches_without_an_openai_key() {
15503 use std::os::unix::fs::PermissionsExt;
15504
15505 let _environment = ENV_MUTEX.lock().await;
15506 let _restore = RestoredEnvironment::capture(&["CAR_CODEX_BIN", "OPENAI_API_KEY"]);
15507 let tmp = TempDir::new().unwrap();
15508 let fixture = tmp.path().join("codex-fixture.sh");
15509 std::fs::write(
15510 &fixture,
15511 r#"#!/bin/sh
15512if [ -n "${OPENAI_API_KEY-}" ]; then
15513 echo 'OPENAI_API_KEY leaked' >&2
15514 exit 91
15515fi
15516cat >/dev/null
15517printf '%s\n' '{"type":"turn.started"}'
15518printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture newsroom answer"}}'
15519printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":17,"output_tokens":5}}'
15520"#,
15521 )
15522 .unwrap();
15523 std::fs::set_permissions(&fixture, std::fs::Permissions::from_mode(0o700)).unwrap();
15524 unsafe {
15525 std::env::set_var("CAR_CODEX_BIN", &fixture);
15526 std::env::set_var("OPENAI_API_KEY", "must-not-reach-codex");
15527 }
15528
15529 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
15530 let mut request = GenerateRequest {
15531 prompt: "write a brief".into(),
15532 params: GenerateParams {
15533 max_tokens: 1056,
15534 strict_model: true,
15535 ..Default::default()
15536 },
15537 ..Default::default()
15538 };
15539 pin_exact_model_id(&mut request, "openai/gpt-5.6-sol:high".into()).unwrap();
15540 let result = engine.generate_tracked(request).await.unwrap();
15541
15542 assert_eq!(result.model_used, "openai/gpt-5.6-sol:high");
15543 assert_eq!(result.text, "fixture newsroom answer");
15544 assert_eq!(result.usage.as_ref().unwrap().total_tokens, 22);
15545 assert_eq!(result.stop_reason, None, "Codex reports no finish reason");
15546 assert!(result.tool_calls.is_empty());
15547 }
15548
15549 #[tokio::test]
15550 async fn isolated_test_child_removes_parent_auth_lock_override() {
15551 const CHILD_SENTINEL: &str = "CAR_AUTH_LOCK_SANITIZER_CHILD";
15552 if std::env::var_os(CHILD_SENTINEL).is_some() {
15553 assert!(
15554 std::env::var_os("CAR_AUTH_LOCK_PATH").is_none(),
15555 "an isolated credential test must not inherit the invoking process's auth lock"
15556 );
15557 return;
15558 }
15559
15560 let _environment = ENV_MUTEX.lock().await;
15561 let _restore = RestoredEnvironment::capture(&["CAR_AUTH_LOCK_PATH"]);
15562 unsafe {
15563 std::env::set_var(
15564 "CAR_AUTH_LOCK_PATH",
15565 "/sentinel/must-not-reach-isolated-child.lock",
15566 );
15567 }
15568 assert!(!crate::run_in_isolated_test_process(
15569 "tests::isolated_test_child_removes_parent_auth_lock_override",
15570 CHILD_SENTINEL,
15571 ));
15572 }
15573
15574 fn test_config(models_dir: PathBuf) -> InferenceConfig {
15575 let state_root = models_dir
15579 .parent()
15580 .map(Path::to_path_buf)
15581 .unwrap_or_else(|| models_dir.clone());
15582 InferenceConfig {
15583 models_dir,
15584 state_root,
15585 device: None,
15586 generation_model: "Qwen3-0.6B".into(),
15587 preferred_generation_model: None,
15588 embedding_model: "Qwen3-Embedding-0.6B".into(),
15589 preferred_embedding_model: None,
15590 classification_model: "Qwen3-0.6B".into(),
15591 preferred_classification_model: None,
15592 }
15593 }
15594
15595 fn metal_mac_for_fit(ram_gb: u64) -> HardwareInfo {
15596 HardwareInfo {
15597 os: "macos".into(),
15598 arch: "aarch64".into(),
15599 cpu_cores: 8,
15600 total_ram_mb: ram_gb * 1024,
15601 gpu_backend: crate::hardware::GpuBackend::Metal,
15602 gpu_memory_mb: None,
15603 gpu_devices: vec![],
15604 recommended_model: String::new(),
15605 recommended_context: 8_192,
15606 max_model_mb: 0,
15607 }
15608 }
15609
15610 #[test]
15617 fn unified_rows_carry_fit_per_machine_and_keep_deprecated_rows() {
15618 if !crate::run_in_isolated_test_process(
15619 "tests::unified_rows_carry_fit_per_machine_and_keep_deprecated_rows",
15620 "CAR_UNIFIED_ROWS_FIT_TEST_CHILD",
15621 ) {
15622 return;
15623 }
15624
15625 let _credential_scope = crate::openrouter::test_credential_scope();
15632 crate::openrouter::set_test_credential(Some("unified-rows-fit-test-key"));
15633 let secret_activity_before = car_secrets::secret_store_activity();
15634 let root = tempfile::tempdir().unwrap();
15635 let config = test_config(root.path().join("weights"));
15636 let mut engine = InferenceEngine::new(config);
15637 let mut retired = engine
15638 .list_schemas()
15639 .into_iter()
15640 .find(|schema| schema.id == "mlx/qwen3-4b:4bit")
15641 .expect("builtin 4B MLX row");
15642 retired.id = "test/retired-4b:4bit".into();
15643 retired.name = "Retired 4B".into();
15644 retired.deprecated = true;
15645 engine.register_model(retired);
15646 let policy = resource_policy::ResourcePolicy::everyday();
15647
15648 let at_8 = engine.list_models_unified_for(&metal_mac_for_fit(8), &policy);
15649 let at_32 = engine.list_models_unified_for(&metal_mac_for_fit(32), &policy);
15650 let ids = |rows: &[ModelInfo]| rows.iter().map(|row| row.id.clone()).collect::<Vec<_>>();
15651 assert_eq!(
15652 ids(&at_8),
15653 ids(&at_32),
15654 "the machine never removes or reorders a row"
15655 );
15656 let row = |rows: &[ModelInfo], id: &str| {
15657 rows.iter()
15658 .find(|row| row.id == id)
15659 .unwrap_or_else(|| panic!("{id} missing"))
15660 .clone()
15661 };
15662
15663 let eight_b_small = row(&at_8, "mlx/qwen3-8b:4bit");
15664 let eight_b_large = row(&at_32, "mlx/qwen3-8b:4bit");
15665 assert_eq!(eight_b_small.fit, ModelFitStatus::TooBig);
15666 assert_eq!(eight_b_large.fit, ModelFitStatus::Fits);
15667 assert!(eight_b_small.platform_compatible && eight_b_large.platform_compatible);
15668 assert_eq!(
15669 eight_b_small.estimated_peak_mb, eight_b_large.estimated_peak_mb,
15670 "the estimate is the model's; only the budget differs"
15671 );
15672 assert!(eight_b_small.estimated_peak_mb.is_some_and(|mb| mb > 4_800));
15673 assert_eq!(eight_b_small.family.as_deref(), Some("qwen3"));
15674 assert!(eight_b_small.version.is_some());
15675
15676 let retired = row(&at_8, "test/retired-4b:4bit");
15677 assert!(retired.deprecated, "deprecated rows stay listed, flagged");
15678 assert!(!eight_b_small.deprecated);
15679
15680 let mut saw_remote = false;
15681 for remote in at_8.iter().filter(|row| !row.is_local) {
15682 saw_remote = true;
15683 assert_eq!(remote.fit, ModelFitStatus::Fits, "{}", remote.id);
15684 assert!(remote.platform_compatible, "{}", remote.id);
15685 assert_eq!(remote.estimated_peak_mb, None, "{}", remote.id);
15686 assert_eq!(
15687 remote.family, None,
15688 "{}: no upstream identifier here",
15689 remote.id
15690 );
15691 assert_eq!(remote.version, None, "{}", remote.id);
15692 }
15693 assert!(saw_remote);
15694 assert_eq!(
15695 car_secrets::secret_store_activity(),
15696 secret_activity_before,
15697 "catalog snapshots must use the injected credential without consulting host secrets"
15698 );
15699
15700 for (small, large) in at_8.iter().zip(&at_32) {
15703 let strip = |row: &ModelInfo| {
15704 let mut value = serde_json::to_value(row).unwrap();
15705 let object = value.as_object_mut().unwrap();
15706 for key in ["fit", "estimated_peak_mb", "platform_compatible"] {
15707 object.remove(key);
15708 }
15709 value
15710 };
15711 assert_eq!(strip(small), strip(large), "{}", small.id);
15712 }
15713 }
15714
15715 #[tokio::test]
15716 async fn explicit_model_blocked_by_zero_budget_does_not_substitute_or_download() {
15717 let root = tempfile::tempdir().unwrap();
15718 let models_dir = root.path().join("weights");
15719 let config = test_config(models_dir.clone());
15720 let repository =
15721 crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
15722 crate::resource_policy::ResourcePolicyRepository::save(
15723 &repository,
15724 &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
15725 )
15726 .unwrap();
15727 let engine = InferenceEngine::new(config);
15728
15729 let error = engine
15730 .generate_tracked(GenerateRequest {
15731 prompt: "hello".into(),
15732 model: Some("mlx/qwen3-4b:4bit".into()),
15733 ..Default::default()
15734 })
15735 .await
15736 .unwrap_err();
15737
15738 assert!(matches!(
15739 error,
15740 InferenceError::LocalResourceBlocked {
15741 preflight: crate::resource_policy::LocalLoadPreflight {
15742 verdict: crate::resource_policy::LocalLoadVerdict::DisabledByPolicy,
15743 ..
15744 },
15745 ..
15746 }
15747 ));
15748 assert!(
15749 !models_dir.exists() || std::fs::read_dir(models_dir).unwrap().next().is_none(),
15750 "admission must happen before download/load"
15751 );
15752 }
15753
15754 #[tokio::test]
15755 async fn external_vllm_mlx_bypasses_local_admission_while_managed_artifact_does_not() {
15756 let schema = crate::vllm_mlx::to_model_schema(
15757 &crate::vllm_mlx::DiscoveredModel {
15758 id: "mlx-community/Qwen3-4B-4bit".into(),
15759 owned_by: None,
15760 },
15761 "http://localhost:8000",
15762 );
15763 assert!(!InferenceEngine::requires_local_admission(&schema));
15764
15765 let root = tempfile::tempdir().unwrap();
15766 let config = test_config(root.path().join("weights"));
15767 let repository =
15768 crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
15769 crate::resource_policy::ResourcePolicyRepository::save(
15770 &repository,
15771 &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
15772 )
15773 .unwrap();
15774 let engine = InferenceEngine::new(config);
15775 let (external, reservation) = engine
15776 .vllm_live_schema(schema.clone(), None, 0)
15777 .await
15778 .unwrap();
15779 assert!(matches!(
15780 external.source,
15781 ModelSource::VllmMlx { ref endpoint, .. } if endpoint == "http://localhost:8000"
15782 ));
15783 assert!(reservation.is_none());
15784
15785 let mut managed = schema;
15786 managed.source = ModelSource::ManagedVllmMlx {
15787 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
15788 hf_weight_file: None,
15789 };
15790 assert!(InferenceEngine::requires_local_admission(&managed));
15791 let error = engine
15792 .reserve_local_request(&managed, 0)
15793 .expect_err("zero-GB policy must reject before vllm runtime/download/spawn");
15794 assert!(matches!(
15795 error,
15796 InferenceError::LocalResourceBlocked {
15797 preflight: crate::resource_policy::LocalLoadPreflight {
15798 verdict: crate::resource_policy::LocalLoadVerdict::DisabledByPolicy,
15799 ..
15800 },
15801 ..
15802 }
15803 ));
15804 }
15805
15806 #[cfg(unix)]
15807 async fn assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(
15808 streaming: bool,
15809 cancel_first: bool,
15810 ) {
15811 struct FixedProbe;
15812 impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
15813 fn available_memory_mb(
15814 &self,
15815 ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
15816 Ok(Some(24_000))
15817 }
15818 }
15819
15820 let Some(python) = crate::vllm_runtime::test_python_interpreter() else {
15821 panic!("a real Python interpreter is required for the managed-vllm dispatch fixture");
15822 };
15823 let root = tempfile::tempdir().unwrap();
15824 let script = root.path().join("fake-vllm-mlx");
15825 let spawned = root.path().join("spawned");
15826 let release = root.path().join("release");
15827 std::fs::write(
15828 &script,
15829 format!(
15830 "#!{}\n\
15831 import http.server, os, sys, time\n\
15832 marker = {:?}\n\
15833 release = {:?}\n\
15834 open(marker, 'w').close()\n\
15835 while not os.path.exists(release): time.sleep(0.01)\n\
15836 port = int(sys.argv[sys.argv.index('--port') + 1])\n\
15837 class H(http.server.BaseHTTPRequestHandler):\n\
15838 \x20 def do_GET(self):\n\
15839 \x20 self.send_response(200); self.end_headers(); self.wfile.write(b'ok')\n\
15840 \x20 def do_POST(self):\n\
15841 \x20 length = int(self.headers.get('content-length', '0'))\n\
15842 \x20 request = self.rfile.read(length).replace(b' ', b'')\n\
15843 \x20 if b'\"stream\":true' in request:\n\
15844 \x20 body = b'data: {{\"id\":\"fixture\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"round13-stream\"}},\"finish_reason\":null}}]}}\\n\\ndata: {{\"id\":\"fixture\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\\n\\ndata: [DONE]\\n\\n'\n\
15845 \x20 content_type = 'text/event-stream'\n\
15846 \x20 else:\n\
15847 \x20 body = b'{{\"id\":\"fixture\",\"object\":\"chat.completion\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"message\":{{\"role\":\"assistant\",\"content\":\"round13-ok\"}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}}}'\n\
15848 \x20 content_type = 'application/json'\n\
15849 \x20 self.send_response(200); self.send_header('content-type', content_type); self.send_header('content-length', str(len(body))); self.end_headers(); self.wfile.write(body)\n\
15850 \x20 def log_message(self, *args): pass\n\
15851 http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()\n",
15852 python.display(),
15853 spawned.display().to_string(),
15854 release.display().to_string(),
15855 ),
15856 )
15857 .unwrap();
15858 use std::os::unix::fs::PermissionsExt;
15859 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
15860
15861 let models_dir = root.path().join("models");
15862 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
15863 let mut schema = crate::registry::builtin_catalog()
15864 .into_iter()
15865 .find(ModelSchema::is_car_managed_vllm_mlx)
15866 .expect("managed vllm fixture schema");
15867 schema.id = "vllm-mlx/round12-singleflight".into();
15868 schema.name = "round12-singleflight".into();
15869 schema.cost.size_mb = Some(1);
15870 schema.cost.ram_mb = Some(1);
15871 schema.source = ModelSource::ManagedVllmMlx {
15872 hf_repo: "fixture/round12-singleflight".into(),
15873 hf_weight_file: None,
15874 };
15875 let model_dir = models_dir.join(&schema.name);
15876 std::fs::create_dir_all(&model_dir).unwrap();
15877 std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
15878 std::fs::write(model_dir.join("model.safetensors"), b"fixture").unwrap();
15879 engine
15880 .unified_registry
15881 .register_project_model(schema.clone());
15882
15883 let coordinator = Arc::new(
15884 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
15885 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
15890 crate::hardware::HardwareInfo::detect(),
15891 Arc::new(FixedProbe),
15892 ),
15893 );
15894 engine.local_admission = coordinator.clone();
15895 engine.vllm_pool = Arc::new(crate::vllm_pool::VllmServerPool::with_test_runtime(
15896 std::time::Duration::from_secs(300),
15897 coordinator,
15898 script,
15899 ));
15900 let engine = Arc::new(engine);
15901 let model_id = schema.id.clone();
15902 let mut request = GenerateRequest {
15903 prompt: "hello from round 13".into(),
15904 model: Some(model_id.clone()),
15905 ..Default::default()
15906 };
15907 request.params.strict_model = true;
15912 let (estimated_input, _, _) = engine.estimated_tokens(&request, Some(&model_id));
15913 let expected_footprint = estimated_input.saturating_add(request.params.max_tokens);
15914 let expected_estimate = engine
15915 .local_model_preflight(&model_id, expected_footprint)
15916 .unwrap()
15917 .estimate;
15918 let expected_request_overhead_mb = expected_estimate
15919 .runtime_overhead_mb
15920 .saturating_add(expected_estimate.context_overhead_mb)
15921 .saturating_add(expected_estimate.transient_margin_mb);
15922
15923 async fn execute(
15924 engine: Arc<InferenceEngine>,
15925 request: GenerateRequest,
15926 streaming: bool,
15927 direct_vllm: bool,
15928 ) -> Result<String, InferenceError> {
15929 if direct_vllm {
15930 let model_id = request.model.as_deref().expect("explicit fixture model");
15931 let schema = engine
15932 .unified_registry
15933 .get(model_id)
15934 .cloned()
15935 .expect("fixture schema");
15936 let (schema, _) = engine.vllm_live_schema(schema, None, 0).await?;
15937 return match schema.source {
15938 ModelSource::VllmMlx { endpoint, .. } => Ok(endpoint),
15939 source => Err(InferenceError::InferenceFailed(format!(
15940 "fixture did not resolve to a vllm endpoint: {source:?}"
15941 ))),
15942 };
15943 }
15944 if !streaming {
15945 return engine
15946 .generate_tracked(request)
15947 .await
15948 .map(|result| result.text);
15949 }
15950 let mut tracked = engine.generate_tracked_stream(request).await?;
15951 let mut accumulator = crate::stream::StreamAccumulator::default();
15952 while let Some(event) = tracked.events.recv().await {
15953 let done = matches!(event, crate::stream::StreamEvent::Done { .. });
15954 accumulator.push(&event);
15955 if done {
15956 break;
15957 }
15958 }
15959 Ok(accumulator.finish().0)
15960 }
15961
15962 let first_engine = engine.clone();
15963 let first_request = request.clone();
15964 let mut first = tokio::spawn(async move {
15965 execute(first_engine, first_request, streaming, cancel_first).await
15966 });
15967 tokio::time::timeout(std::time::Duration::from_secs(5), async {
15968 while !spawned.exists() {
15969 if first.is_finished() {
15970 let result = (&mut first).await;
15971 panic!("first dispatch ended before spawn: {result:?}");
15972 }
15973 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
15974 }
15975 })
15976 .await
15977 .expect("first request must spawn before publication");
15978 if !cancel_first {
15979 let during_startup = engine.local_model_preflight(&model_id, 0).unwrap();
15980 assert_eq!(
15981 during_startup.active_reservations_mb, expected_request_overhead_mb,
15982 "the cold-to-pending transfer must retain all request context/KV overhead"
15983 );
15984 assert_eq!(
15985 during_startup.resident_model_mb, expected_estimate.weights_mb,
15986 "the pending allocation must replace the exact cold-weight charge"
15987 );
15988 assert_eq!(
15989 during_startup
15990 .active_reservations_mb
15991 .saturating_add(during_startup.resident_model_mb),
15992 expected_estimate.estimated_peak_mb,
15993 "active request overhead plus pending weights must preserve the full admitted footprint"
15994 );
15995 }
15996
15997 let second_engine = engine.clone();
15998 let mut second =
15999 tokio::spawn(
16000 async move { execute(second_engine, request, streaming, cancel_first).await },
16001 );
16002 assert!(
16003 tokio::time::timeout(std::time::Duration::from_millis(100), &mut second)
16004 .await
16005 .is_err(),
16006 "request 2 must wait for request 1 to publish instead of failing on startup state"
16007 );
16008
16009 let first_result = if cancel_first {
16010 std::fs::remove_file(&spawned).unwrap();
16011 first.abort();
16012 let _ = first.await;
16013 tokio::time::timeout(std::time::Duration::from_secs(5), async {
16014 while !spawned.exists() {
16015 if second.is_finished() {
16016 let result = (&mut second).await;
16017 panic!("waiter ended before replacement spawn: {result:?}");
16018 }
16019 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
16020 }
16021 })
16022 .await
16023 .expect("waiter must continue after cancelled owner teardown");
16024 None
16025 } else {
16026 std::fs::write(&release, b"release").unwrap();
16027 Some(
16028 tokio::time::timeout(std::time::Duration::from_secs(5), first)
16029 .await
16030 .expect("first dispatch completes")
16031 .unwrap()
16032 .unwrap(),
16033 )
16034 };
16035 std::fs::write(&release, b"release").unwrap();
16036 let second_result = tokio::time::timeout(std::time::Duration::from_secs(5), second)
16037 .await
16038 .expect("second dispatch completes")
16039 .unwrap()
16040 .unwrap();
16041 let expected = if cancel_first {
16042 None
16043 } else if streaming {
16044 Some("round13-stream")
16045 } else {
16046 Some("round13-ok")
16047 };
16048 if let (Some(first_result), Some(expected)) = (first_result, expected) {
16049 assert_eq!(first_result, expected);
16050 }
16051 if let Some(expected) = expected {
16052 assert_eq!(second_result, expected);
16053 } else {
16054 assert!(second_result.starts_with("http://127.0.0.1:"));
16055 }
16056 assert_eq!(engine.vllm_pool.len().await, 1);
16057 assert!(engine
16058 .vllm_pool
16059 .release_model_if_present(&model_id)
16060 .await
16061 .unwrap());
16062 }
16063
16064 #[cfg(unix)]
16065 #[tokio::test]
16066 async fn concurrent_managed_vllm_generate_waits_before_outer_reservation() {
16067 assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(false, false).await;
16068 }
16069
16070 #[cfg(unix)]
16071 #[tokio::test]
16072 async fn concurrent_managed_vllm_stream_waits_before_outer_reservation() {
16073 assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(true, false).await;
16074 }
16075
16076 #[cfg(unix)]
16077 #[tokio::test]
16078 async fn managed_vllm_waiter_continues_after_startup_owner_cancellation() {
16079 assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(false, true).await;
16080 }
16081
16082 #[tokio::test]
16083 async fn same_state_root_engines_share_runtime_components() {
16084 const CHILD_ENV: &str = "CAR_TWO_ENGINE_RUNTIME_TEST_CHILD";
16085 if std::env::var_os(CHILD_ENV).is_some() {
16086 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
16087 return;
16088 }
16089 let root = tempfile::tempdir().unwrap();
16090 let first = InferenceEngine::new(test_config(root.path().join("weights")));
16091 let second = InferenceEngine::new(test_config(root.path().join("weights")));
16092
16093 assert!(Arc::ptr_eq(&first.model_budget, &second.model_budget));
16094 assert!(Arc::ptr_eq(&first.vllm_pool, &second.vllm_pool));
16095 assert!(Arc::ptr_eq(
16096 &first.resource_policy_generation,
16097 &second.resource_policy_generation
16098 ));
16099 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
16100 {
16101 assert!(Arc::ptr_eq(&first.mlx_backends, &second.mlx_backends));
16102 assert!(Arc::ptr_eq(&first.local_backends, &second.local_backends));
16103 assert!(Arc::ptr_eq(&first.flux_cache, &second.flux_cache));
16104 assert!(Arc::ptr_eq(&first.ltx_cache, &second.ltx_cache));
16105 assert!(Arc::ptr_eq(&first.kokoro_cache, &second.kokoro_cache));
16106 }
16107 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
16108 {
16109 assert!(Arc::ptr_eq(&first.backend, &second.backend));
16110 assert!(Arc::ptr_eq(
16111 &first.embedding_backend,
16112 &second.embedding_backend
16113 ));
16114 }
16115
16116 let loads = Arc::new(std::sync::atomic::AtomicU64::new(0));
16117 let first_cache = first._runtime_scope.load_probe.clone();
16118 let second_cache = second._runtime_scope.load_probe.clone();
16119 let mut threads = Vec::new();
16120 for cache in [first_cache, second_cache] {
16121 let loads = loads.clone();
16122 threads.push(std::thread::spawn(move || {
16123 cache
16124 .get_or_load::<()>("same/model", 1, || {
16125 loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16126 std::thread::sleep(std::time::Duration::from_millis(20));
16127 Ok(())
16128 })
16129 .unwrap()
16130 }));
16131 }
16132 let handles = threads
16133 .into_iter()
16134 .map(|thread| thread.join().unwrap())
16135 .collect::<Vec<_>>();
16136 assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
16137 assert!(Arc::ptr_eq(&handles[0], &handles[1]));
16138
16139 let child = tokio::process::Command::new(std::env::current_exe().unwrap())
16140 .arg("--exact")
16141 .arg("tests::same_state_root_engines_share_runtime_components")
16142 .env(CHILD_ENV, "1")
16143 .stdin(std::process::Stdio::null())
16144 .stdout(std::process::Stdio::null())
16145 .stderr(std::process::Stdio::null())
16146 .kill_on_drop(true)
16147 .spawn()
16148 .unwrap();
16149 first
16150 .vllm_pool
16151 .insert_test_process("vllm-mlx/two-engine", child)
16152 .await;
16153 first.local_admission.mark_resident_allocation(
16154 "vllm-mlx/two-engine",
16155 &resource_policy::vllm_process_allocation_id("vllm-mlx/two-engine"),
16156 1,
16157 );
16158 assert!(second.vllm_pool.contains("vllm-mlx/two-engine").await);
16159 drop(first);
16160 assert!(second.vllm_pool.contains("vllm-mlx/two-engine").await);
16161 assert!(second.local_admission.is_resident("vllm-mlx/two-engine"));
16162 assert!(second.vllm_pool.evict_model("vllm-mlx/two-engine").await);
16163 assert!(!second.local_admission.is_resident("vllm-mlx/two-engine"));
16164 }
16165
16166 #[tokio::test]
16167 async fn last_engine_drop_reaps_vllm_before_new_runtime_admission() {
16168 const CHILD_ENV: &str = "CAR_LAST_ENGINE_VLLM_DROP_TEST_CHILD";
16169 if std::env::var_os(CHILD_ENV).is_some() {
16170 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
16171 return;
16172 }
16173 let root = tempfile::tempdir().unwrap();
16174 let config = test_config(root.path().join("weights"));
16175 let engine = InferenceEngine::new(config.clone());
16176 let coordinator = engine.local_admission.clone();
16177 let schema = engine
16178 .unified_registry
16179 .all()
16180 .find(|schema| schema.is_vllm_mlx())
16181 .cloned()
16182 .expect("supervised vllm schema");
16183 let child = tokio::process::Command::new(std::env::current_exe().unwrap())
16184 .arg("--exact")
16185 .arg("tests::last_engine_drop_reaps_vllm_before_new_runtime_admission")
16186 .env(CHILD_ENV, "1")
16187 .stdin(std::process::Stdio::null())
16188 .stdout(std::process::Stdio::null())
16189 .stderr(std::process::Stdio::null())
16190 .kill_on_drop(true)
16191 .spawn()
16192 .unwrap();
16193 engine
16194 .vllm_pool
16195 .insert_test_process(&schema.id, child)
16196 .await;
16197 coordinator.mark_resident_allocation(
16198 &schema.id,
16199 &resource_policy::vllm_process_allocation_id(&schema.id),
16200 1,
16201 );
16202
16203 drop(engine);
16204
16205 tokio::time::timeout(std::time::Duration::from_secs(2), async {
16206 while coordinator.teardown_pending(&schema.id) {
16207 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
16208 }
16209 })
16210 .await
16211 .expect("last runtime drop must eventually confirm supervised child exit");
16212 assert!(!coordinator.is_resident(&schema.id));
16213 let next = InferenceEngine::new(config);
16214 assert!(Arc::ptr_eq(&coordinator, &next.local_admission));
16215 let preflight = next.local_model_preflight(&schema.id, 0).unwrap();
16216 assert_eq!(preflight.resident_model_mb, 0);
16217 assert!(preflight.estimated_incremental_mb >= preflight.estimate.weights_mb);
16218 }
16219
16220 #[cfg(unix)]
16221 #[test]
16222 fn symlinked_state_roots_share_exact_coordinator_and_runtime() {
16223 use std::os::unix::fs::symlink;
16224
16225 let fixture = tempfile::tempdir().unwrap();
16226 let real = fixture.path().join("real-state");
16227 std::fs::create_dir(&real).unwrap();
16228 let alias = fixture.path().join("state-alias");
16229 symlink(&real, &alias).unwrap();
16230
16231 let first = InferenceEngine::new(test_config(real.join("weights")));
16232 let second = InferenceEngine::new(test_config(alias.join("weights")));
16233 assert!(Arc::ptr_eq(&first.local_admission, &second.local_admission));
16234 assert!(Arc::ptr_eq(&first._runtime_scope, &second._runtime_scope));
16235 assert!(Arc::ptr_eq(&first.model_budget, &second.model_budget));
16236 }
16237
16238 #[tokio::test]
16239 async fn stream_reservation_lives_until_returned_receiver_is_released() {
16240 let root = tempfile::tempdir().unwrap();
16241 let engine = InferenceEngine::new(test_config(root.path().join("weights")));
16242 let schema = crate::registry::builtin_catalog()
16243 .into_iter()
16244 .find(|schema| schema.is_local() && !schema.is_vllm_mlx())
16245 .expect("local model schema");
16246 engine.local_admission.mark_resident(&schema.id, 1);
16247 let reservation = engine.reserve_local_request(&schema, 64).unwrap();
16248 assert_eq!(engine.local_admission.active_request_count(&schema.id), 1);
16249 let (source_tx, source_rx) = tokio::sync::mpsc::channel(1);
16250 let returned =
16251 InferenceEngine::hold_optional_reservation_for_stream(source_rx, Some(reservation));
16252
16253 drop(returned);
16254 source_tx
16255 .send(stream::StreamEvent::TextDelta("release".into()))
16256 .await
16257 .unwrap();
16258 for _ in 0..20 {
16259 if engine.local_admission.active_request_count(&schema.id) == 0 {
16260 break;
16261 }
16262 tokio::task::yield_now().await;
16263 }
16264 assert_eq!(engine.local_admission.active_request_count(&schema.id), 0);
16265
16266 let compact_source = include_str!("lib.rs")
16267 .split_whitespace()
16268 .collect::<String>();
16269 let remote_handoff = [
16270 "Self::hold_optional_reservation_for_stream",
16271 "(receiver,",
16272 "candidate_reservation.take(),",
16273 ");",
16274 ]
16275 .concat();
16276 assert!(compact_source.contains(&remote_handoff));
16277 }
16278
16279 #[tokio::test]
16280 async fn residual_voice_allocation_blocks_removal_with_typed_error() {
16281 let root = tempfile::tempdir().unwrap();
16282 let engine = InferenceEngine::new(test_config(root.path().join("weights")));
16283 engine
16284 .local_admission
16285 .mark_resident("voice/removal-fixture", 1);
16286
16287 let error = match engine
16288 .prepare_local_model_removal("voice/removal-fixture")
16289 .await
16290 {
16291 Err(error) => error,
16292 Ok(_) => panic!("voice allocation must be released by its owner first"),
16293 };
16294 assert!(matches!(
16295 error,
16296 crate::resource_policy::ModelMaintenanceError::ResidualResidency {
16297 model_id,
16298 allocation_ids,
16299 } if model_id == "voice/removal-fixture"
16300 && allocation_ids == vec!["voice/removal-fixture"]
16301 ));
16302 }
16303
16304 #[tokio::test]
16305 async fn catalog_voice_id_blocks_removal_of_live_provider_alias() {
16306 let root = tempfile::tempdir().unwrap();
16307 let engine = InferenceEngine::new(test_config(root.path().join("weights")));
16308 engine.local_admission.register_model_aliases(
16309 "voice/parakeet-tdt-0.6b",
16310 ["mlx/parakeet-tdt-0.6b-v3:default"],
16311 );
16312 let mut reservation = engine
16313 .local_admission
16314 .reserve_measured_host_allocation(
16315 "voice/parakeet-tdt-0.6b",
16316 "voice/parakeet-tdt-0.6b#voice-allocation-0",
16317 1024 * 1024,
16318 0,
16319 )
16320 .unwrap();
16321 reservation.publish_resident_weights(1024 * 1024);
16322 drop(reservation);
16323
16324 let result = engine
16325 .prepare_local_model_removal("mlx/parakeet-tdt-0.6b-v3:default")
16326 .await;
16327 assert!(matches!(
16328 result,
16329 Err(crate::resource_policy::ModelMaintenanceError::ResidualResidency { .. })
16330 ));
16331 }
16332
16333 struct RefusingRemovalOffload;
16334
16335 #[async_trait::async_trait]
16336 impl crate::offload::LocalGenerationOffload for RefusingRemovalOffload {
16337 async fn generate(
16338 &self,
16339 _request: GenerateRequest,
16340 ) -> Result<InferenceResult, InferenceError> {
16341 unreachable!("removal test does not generate")
16342 }
16343
16344 async fn stream(
16345 &self,
16346 _request: GenerateRequest,
16347 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
16348 unreachable!("removal test does not stream")
16349 }
16350
16351 async fn resident_models(&self) -> Vec<String> {
16352 vec!["worker/refuses-release".into()]
16353 }
16354
16355 async fn release_model(&self, _model_id: &str) -> Result<bool, InferenceError> {
16356 Ok(false)
16357 }
16358 }
16359
16360 #[tokio::test]
16361 async fn worker_release_boolean_is_required_for_model_removal() {
16362 let _offload_guard = crate::offload::test_offload_lock().lock().await;
16363 crate::offload::set_local_offload(Some(Arc::new(RefusingRemovalOffload)));
16364 let root = tempfile::tempdir().unwrap();
16365 let engine = InferenceEngine::new(test_config(root.path().join("weights")));
16366 let result = engine
16367 .prepare_local_model_removal("worker/refuses-release")
16368 .await;
16369 crate::offload::set_local_offload(None);
16370
16371 assert!(matches!(
16372 result,
16373 Err(crate::resource_policy::ModelMaintenanceError::WorkerReleaseUnacknowledged(
16374 model_id
16375 )) if model_id == "worker/refuses-release"
16376 ));
16377 }
16378
16379 #[test]
16380 fn transient_mlx_vlm_allocation_never_becomes_resident() {
16381 struct FixedProbe;
16382 impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
16383 fn available_memory_mb(
16384 &self,
16385 ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
16386 Ok(Some(24_000))
16387 }
16388 }
16389
16390 let root = tempfile::tempdir().unwrap();
16391 let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
16392 engine.local_admission = Arc::new(
16393 crate::resource_policy::LocalAdmissionCoordinator::with_probe(
16394 crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
16395 crate::hardware::HardwareInfo {
16396 total_ram_mb: 32 * 1024,
16397 ..crate::hardware::HardwareInfo::detect()
16398 },
16399 Arc::new(FixedProbe),
16400 ),
16401 );
16402 let mut schema = crate::registry::builtin_catalog()
16403 .into_iter()
16404 .find(|schema| schema.tags.iter().any(|tag| tag == "mlx-vlm-cli"))
16405 .expect("one-shot mlx-vlm schema");
16406 schema.param_count = "1M".into();
16407 schema.quantization = Some(Quantization::parse("Q4"));
16408 let mut reservation = engine.reserve_local_request(&schema, 64).unwrap();
16409 InferenceEngine::reconcile_transient_local_allocation(&mut reservation, 1024 * 1024)
16410 .unwrap();
16411 drop(reservation);
16412 assert!(!engine.local_admission.is_resident(&schema.id));
16413 }
16414
16415 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
16416 #[test]
16417 fn non_apple_backend_map_keeps_two_local_model_identities_exact() {
16418 let mut backends = std::collections::HashMap::<String, u32>::new();
16419 backends.insert("local/model-a".into(), 1);
16420 backends.insert("local/model-b".into(), 2);
16421 assert_eq!(backends.get("local/model-a"), Some(&1));
16422 assert_eq!(backends.get("local/model-b"), Some(&2));
16423 assert_eq!(backends.remove("local/model-a"), Some(1));
16424 assert!(!backends.contains_key("local/model-a"));
16425 assert_eq!(backends.get("local/model-b"), Some(&2));
16426 }
16427
16428 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
16429 #[tokio::test]
16430 async fn non_apple_remote_classification_at_zero_gb_routes_without_local_allocation() {
16431 use wiremock::matchers::{method, path};
16432 use wiremock::{Mock, MockServer, ResponseTemplate};
16433
16434 let _credential_scope = crate::openrouter::test_credential_scope();
16435 crate::openrouter::set_test_credential(Some("test-openrouter-key"));
16436 let server = MockServer::start().await;
16437 Mock::given(method("POST"))
16438 .and(path("/v1/chat/completions"))
16439 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
16440 "choices": [{
16441 "message": {"role": "assistant", "content": "positive"},
16442 "finish_reason": "stop"
16443 }],
16444 "usage": {"prompt_tokens": 16, "completion_tokens": 1}
16445 })))
16446 .mount(&server)
16447 .await;
16448
16449 let fixture = tempfile::tempdir().unwrap();
16450 let config = test_config(fixture.path().join("weights"));
16451 let repository =
16452 crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
16453 crate::resource_policy::ResourcePolicyRepository::save(
16454 &repository,
16455 &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
16456 )
16457 .unwrap();
16458 let mut engine = InferenceEngine::new(config);
16459 let mut remote = crate::openrouter::curated_schemas()
16460 .into_iter()
16461 .next()
16462 .expect("OpenRouter schema");
16463 remote.id = "openrouter/test/classifier".into();
16464 remote.name = remote.id.clone();
16465 if let ModelSource::RemoteApi { endpoint, .. } = &mut remote.source {
16466 *endpoint = server.uri();
16467 }
16468 let remote_id = remote.id.clone();
16469 engine.unified_registry.register_project_model(remote);
16470
16471 let result = engine
16472 .classify(ClassifyRequest {
16473 text: "a good outcome".into(),
16474 labels: vec!["positive".into(), "negative".into()],
16475 model: Some(remote_id),
16476 })
16477 .await
16478 .unwrap();
16479 assert_eq!(
16480 result.first().map(|item| item.label.as_str()),
16481 Some("positive")
16482 );
16483 assert_eq!(engine.local_admission.resident_model_mb(), 0);
16484 assert!(
16485 !engine.config.models_dir.exists()
16486 || std::fs::read_dir(&engine.config.models_dir)
16487 .unwrap()
16488 .next()
16489 .is_none(),
16490 "remote classification must not download local weights"
16491 );
16492 }
16493
16494 #[test]
16495 fn adaptive_speech_skips_blocked_local_but_explicit_speech_fails() {
16496 let fixture = TempDir::new().unwrap();
16497 let config = InferenceConfig {
16498 models_dir: fixture.path().join("weights"),
16499 state_root: fixture.path().join("state"),
16500 ..Default::default()
16501 };
16502 let repository =
16503 crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
16504 crate::resource_policy::ResourcePolicyRepository::save(
16505 &repository,
16506 &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
16507 )
16508 .unwrap();
16509 let engine = InferenceEngine::new(config);
16510 let local = engine
16511 .unified_registry
16512 .all()
16513 .find(|schema| {
16514 schema.is_local() && schema.has_capability(ModelCapability::SpeechToText)
16515 })
16516 .cloned()
16517 .expect("built-in local STT model");
16518
16519 assert!(matches!(
16520 engine.admit_speech_candidate(&local, false),
16521 SpeechCandidateAdmission::SkipBlocked(_)
16522 ));
16523 assert!(matches!(
16524 engine.admit_speech_candidate(&local, true),
16525 SpeechCandidateAdmission::FailBlocked(InferenceError::LocalResourceBlocked { .. })
16526 ));
16527
16528 let mut os_owned = local;
16529 os_owned.id = "windows/speech-synthesis:test".into();
16530 os_owned.source = ModelSource::WindowsSpeech {};
16531 assert!(matches!(
16532 engine.admit_speech_candidate(&os_owned, true),
16533 SpeechCandidateAdmission::Proceed(None)
16534 ));
16535 }
16536
16537 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
16538 async fn cancelled_native_blocking_work_keeps_its_exact_charge_until_completion() {
16539 let coordinator = std::sync::Arc::new(resource_policy::LocalAdmissionCoordinator::new(
16540 resource_policy::ResourcePolicy::custom_gb(1.0).unwrap(),
16541 crate::hardware::HardwareInfo {
16542 os: "test".into(),
16543 arch: "test".into(),
16544 cpu_cores: 8,
16545 total_ram_mb: 32 * 1024,
16546 gpu_backend: crate::hardware::GpuBackend::Cpu,
16547 gpu_memory_mb: None,
16548 gpu_devices: Vec::new(),
16549 recommended_model: "fixture".into(),
16550 recommended_context: 4096,
16551 max_model_mb: 32 * 1024,
16552 },
16553 ));
16554 let reservation = coordinator
16555 .reserve_measured_host("detached-native-a", 512 * 1024 * 1024, 0)
16556 .unwrap();
16557 let lease = reservation.detached_lease();
16558 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
16559 let release =
16560 std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
16561 let release_worker = release.clone();
16562 let operation = tokio::spawn(async move {
16563 let _request_reservation = reservation;
16564 run_admitted_blocking(lease, move || {
16565 let _ = started_tx.send(());
16566 let (lock, ready) = &*release_worker;
16567 let mut released = lock
16568 .lock()
16569 .unwrap_or_else(std::sync::PoisonError::into_inner);
16570 while !*released {
16571 released = ready
16572 .wait(released)
16573 .unwrap_or_else(std::sync::PoisonError::into_inner);
16574 }
16575 })
16576 .await
16577 });
16578 started_rx.await.unwrap();
16579 operation.abort();
16580 let _ = operation.await;
16581
16582 let blocked = coordinator.reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0);
16583 assert!(
16584 blocked.is_err(),
16585 "cancelling the await must not advertise memory still owned by spawn_blocking"
16586 );
16587
16588 let (lock, ready) = &*release;
16589 *lock
16590 .lock()
16591 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
16592 ready.notify_one();
16593 tokio::time::timeout(std::time::Duration::from_secs(2), async {
16594 loop {
16595 if coordinator
16596 .reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0)
16597 .is_ok()
16598 {
16599 break;
16600 }
16601 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
16602 }
16603 })
16604 .await
16605 .expect("the detached charge must clear after native work really exits");
16606 }
16607
16608 #[test]
16609 fn nonstream_native_text_generation_uses_cancellable_blocking_boundary() {
16610 let source = include_str!("lib.rs");
16611 let mlx = source
16612 .split("async fn generate_mlx(")
16613 .nth(1)
16614 .and_then(|tail| tail.split("async fn generate_local(").next())
16615 .expect("generate_mlx source");
16616 let local = source
16617 .split("async fn generate_local(")
16618 .nth(1)
16619 .and_then(|tail| tail.split("fn apply_top_k_top_p").next())
16620 .expect("generate_local source");
16621
16622 assert!(
16623 mlx.contains("run_admitted_blocking"),
16624 "native MLX decode must yield the Tokio runtime so a WS timeout/cancel can preempt its response waiter"
16625 );
16626 assert!(
16627 local.contains("run_admitted_blocking"),
16628 "polymorphic local decode must yield the Tokio runtime so a WS timeout/cancel can preempt its response waiter"
16629 );
16630 }
16631
16632 #[test]
16633 fn native_mlx_text_turns_prepare_the_backend_once() {
16634 let source = include_str!("lib.rs");
16635 let native_dispatch = source
16636 .split("// A local MLX checkpoint the dedicated Qwen `MlxBackend`")
16637 .nth(1)
16638 .and_then(|tail| tail.split("#[cfg(not(all(target_os = \"macos\"").next())
16639 .expect("native MLX dispatch branch");
16640 let before_images = native_dispatch
16641 .split("if has_images {")
16642 .next()
16643 .expect("text-only prefix");
16644 let image_setup = native_dispatch
16645 .split("if has_images {")
16646 .nth(1)
16647 .and_then(|tail| tail.split("let can_do_vision").next())
16648 .expect("image-only backend setup");
16649
16650 assert_eq!(
16651 before_images.matches("ensure_mlx_backend").count(),
16652 0,
16653 "text turns must reach backend preparation only through generate_mlx"
16654 );
16655 assert_eq!(
16656 image_setup.matches("ensure_mlx_backend").count(),
16657 1,
16658 "the eager capability probe belongs only to the image branch"
16659 );
16660 assert_eq!(
16661 native_dispatch.matches("ensure_mlx_backend").count(),
16662 1,
16663 "no second eager preparation may escape the image branch"
16664 );
16665 }
16666
16667 #[cfg(unix)]
16668 #[tokio::test]
16669 async fn cancelled_speech_subprocess_is_killed_and_reaped_before_charge_clears() {
16670 use std::os::unix::fs::PermissionsExt;
16671
16672 let fixture = TempDir::new().unwrap();
16673 let runtime = SpeechRuntime::new(fixture.path().join("speech-runtime"));
16674 std::fs::create_dir_all(runtime.stt_program.parent().unwrap()).unwrap();
16675 std::fs::write(
16676 &runtime.stt_program,
16677 b"#!/bin/sh\necho started > \"$1\"\nsleep 1\necho continued > \"$2\"\n",
16678 )
16679 .unwrap();
16680 let mut permissions = std::fs::metadata(&runtime.stt_program)
16681 .unwrap()
16682 .permissions();
16683 permissions.set_mode(0o755);
16684 std::fs::set_permissions(&runtime.stt_program, permissions).unwrap();
16685
16686 let coordinator = std::sync::Arc::new(resource_policy::LocalAdmissionCoordinator::new(
16687 resource_policy::ResourcePolicy::custom_gb(1.0).unwrap(),
16688 crate::hardware::HardwareInfo {
16689 total_ram_mb: 32 * 1024,
16690 ..crate::hardware::HardwareInfo::detect()
16691 },
16692 ));
16693 let reservation = coordinator
16694 .reserve_measured_host("mlx-audio-a", 512 * 1024 * 1024, 0)
16695 .unwrap();
16696 let lease = reservation.detached_lease();
16697 let started = fixture.path().join("started");
16698 let continued = fixture.path().join("continued");
16699 let args = vec![
16700 started.display().to_string(),
16701 continued.display().to_string(),
16702 ];
16703 let command = tokio::spawn(async move {
16704 let _request_reservation = reservation;
16705 run_mlx_audio_command(&runtime, "stt.generate", &args, lease).await
16706 });
16707 tokio::time::timeout(std::time::Duration::from_secs(2), async {
16708 while !started.exists() {
16709 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
16710 }
16711 })
16712 .await
16713 .expect("speech fixture child must start");
16714 command.abort();
16715 let _ = command.await;
16716
16717 tokio::time::timeout(std::time::Duration::from_secs(2), async {
16718 loop {
16719 if coordinator
16720 .reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0)
16721 .is_ok()
16722 {
16723 break;
16724 }
16725 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
16726 }
16727 })
16728 .await
16729 .expect("charge clears only after the cancelled child is reaped");
16730 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
16731 assert!(
16732 !continued.exists(),
16733 "the cancelled local speech process must not keep allocating in the background"
16734 );
16735 }
16736
16737 #[test]
16738 fn local_model_eviction_surface_includes_every_in_process_cache() {
16739 let source = include_str!("lib.rs");
16740 for cache in [
16741 "self.mlx_backends.evict_if_idle(model_id)",
16742 "self.local_backends.evict_if_idle(model_id)",
16743 "self.flux_cache.evict_if_idle(model_id)",
16744 "self.ltx_cache.evict_if_idle(model_id)",
16745 "self.kokoro_cache.evict_if_idle(model_id)",
16746 "self.local_backends.evict_idle()",
16747 ] {
16748 assert!(source.contains(cache), "missing eviction seam: {cache}");
16749 }
16750 }
16751
16752 #[tokio::test]
16758 async fn catalog_refresh_uses_authority_hints_without_secret_reads() {
16759 if !crate::run_in_isolated_test_process(
16760 "tests::catalog_refresh_uses_authority_hints_without_secret_reads",
16761 "CAR_CATALOG_ZERO_SECRET_READ_CHILD",
16762 ) {
16763 return;
16764 }
16765 let _environment = ENV_MUTEX.lock().await;
16766 let fixture = TempDir::new().unwrap();
16767 let credential_envs = [
16768 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
16769 car_auth::PARSLEE_API_BASE_KEY,
16770 crate::openrouter::API_KEY_ENV,
16771 "OPENAI_API_KEY",
16772 "ANTHROPIC_API_KEY",
16773 "GOOGLE_API_KEY",
16774 "ELEVENLABS_API_KEY",
16775 ];
16776 let mut restored_names = vec![car_home::ENV_VAR, "CAR_SECRETS_FILE_DIR"];
16777 restored_names.extend(credential_envs);
16778 let _restore = RestoredEnvironment::capture(&restored_names);
16779 unsafe {
16780 std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
16781 std::env::set_var(
16782 "CAR_SECRETS_FILE_DIR",
16783 fixture.path().join("isolated-secrets"),
16784 );
16785 for name in credential_envs {
16786 std::env::remove_var(name);
16787 }
16788 }
16789
16790 let before = car_secrets::secret_store_activity();
16791 let engine = InferenceEngine::new(test_config(fixture.path().join("models")));
16792
16793 let listed = engine.list_models_unified();
16795 let schemas = engine.list_schemas();
16796 for provider in ["parslee", "openai", "anthropic", "google", "elevenlabs"] {
16797 assert!(
16798 schemas.iter().any(|schema| schema.provider == provider),
16799 "real builtin catalog lost the credential-bearing {provider} rows"
16800 );
16801 }
16802 assert!(
16803 schemas.iter().any(|schema| matches!(
16804 schema.source,
16805 ModelSource::RemoteApi {
16806 protocol: ApiProtocol::OpenRouter,
16807 ..
16808 }
16809 )),
16810 "real builtin catalog lost the reviewed OpenRouter rows"
16811 );
16812 assert_eq!(listed.len(), schemas.len());
16813
16814 let schema_refs: Vec<&ModelSchema> = schemas.iter().collect();
16816 let _setup_plan = crate::recommend(
16817 &schema_refs,
16818 &HardwareInfo::detect(),
16819 UseCase::default(),
16820 QualityTier::default(),
16821 Privacy::OnDevice,
16822 );
16823
16824 let _speech = engine.speech_health();
16826 let _concierge = engine.concierge_status(false).await;
16827 let _health = engine.model_health().await;
16828
16829 let after = car_secrets::secret_store_activity();
16830 assert_eq!(
16831 after, before,
16832 "passive builtin catalog surfaces performed secret-store operations"
16833 );
16834 }
16835
16836 #[tokio::test]
16837 async fn list_models_unified_and_model_health_are_zero_secret_store_probes() {
16838 if !crate::run_in_isolated_test_process(
16839 "tests::list_models_unified_and_model_health_are_zero_secret_store_probes",
16840 "CAR_DIRECT_MODEL_SURFACES_ZERO_SECRET_CHILD",
16841 ) {
16842 return;
16843 }
16844 let _environment = ENV_MUTEX.lock().await;
16845 let fixture = TempDir::new().unwrap();
16846 let _restore = RestoredEnvironment::capture(&[
16847 car_home::ENV_VAR,
16848 "CAR_SECRETS_FILE_DIR",
16849 crate::openrouter::API_KEY_ENV,
16850 ]);
16851 unsafe {
16852 std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
16853 std::env::set_var(
16854 "CAR_SECRETS_FILE_DIR",
16855 fixture.path().join("isolated-secrets"),
16856 );
16857 std::env::remove_var(crate::openrouter::API_KEY_ENV);
16858 }
16859 let engine = InferenceEngine::new(test_config(fixture.path().join("models")));
16860 let before = car_secrets::secret_store_activity();
16861
16862 let rows = engine.list_models_unified();
16863 let health = engine.model_health().await;
16864
16865 assert!(!rows.is_empty(), "the unified catalog fixture must exist");
16866 assert!(health.total_models > 0, "the health fixture must exist");
16867 let after = car_secrets::secret_store_activity();
16868 assert_eq!(after.status_attempts, before.status_attempts);
16869 assert_eq!(after.get_attempts, before.get_attempts);
16870 }
16871
16872 #[test]
16882 fn car_home_moves_the_catalog_and_discovery_caches_but_never_the_weights() {
16883 let _environment = crate::openrouter::test_environment_scope();
16884 let prior = std::env::var_os(car_home::ENV_VAR);
16885
16886 unsafe { std::env::remove_var(car_home::ENV_VAR) };
16887 let shared = InferenceConfig::default();
16888 let default_catalog = crate::catalog::cache_path(&shared.state_root);
16889 let default_discovery = crate::discovery::cache_path(&shared.state_models_dir());
16890
16891 let alt = Path::new("/tmp/car-home-inference-cache-test");
16892 unsafe { std::env::set_var(car_home::ENV_VAR, alt) };
16893 let relocated = InferenceConfig::default();
16894 let catalog = crate::catalog::cache_path(&relocated.state_root);
16895 let discovery = crate::discovery::cache_path(&relocated.state_models_dir());
16896
16897 match prior {
16898 Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
16899 None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
16900 }
16901
16902 assert_eq!(catalog, alt.join(crate::catalog::CATALOG_CACHE_FILE));
16903 assert_eq!(
16904 discovery,
16905 alt.join("models")
16906 .join(crate::discovery::DISCOVERED_MODELS_FILE)
16907 );
16908 assert_ne!(
16909 catalog, default_catalog,
16910 "the catalog cache must not resolve back into the shared root",
16911 );
16912 assert_ne!(
16913 discovery, default_discovery,
16914 "the discovery cache must not resolve back into the shared root",
16915 );
16916
16917 assert_eq!(
16918 relocated.models_dir, shared.models_dir,
16919 "the weights cache is machine-global and must not follow CAR_HOME",
16920 );
16921 assert!(
16922 !relocated.models_dir.starts_with(alt),
16923 "the weights cache must not be dragged under the override",
16924 );
16925 }
16926
16927 #[derive(Clone, Copy)]
16928 enum CacheRoutingSurface {
16929 Generate,
16930 Stream,
16931 }
16932
16933 async fn invoke_cache_routed_openrouter(
16939 surface: CacheRoutingSurface,
16940 cache_read_estimate: usize,
16941 cache_write_estimate: usize,
16942 ) -> String {
16943 use wiremock::matchers::{method, path};
16944 use wiremock::{Mock, MockServer, ResponseTemplate};
16945
16946 let _credential_scope = crate::openrouter::test_credential_scope();
16947 crate::openrouter::set_test_credential(Some("test-openrouter-key"));
16948 let server = MockServer::start().await;
16949 let response = match surface {
16950 CacheRoutingSurface::Generate => ResponseTemplate::new(200).set_body_json(
16951 serde_json::json!({
16952 "choices": [{
16953 "message": {"role": "assistant", "content": "cache-route-ok"},
16954 "finish_reason": "stop"
16955 }],
16956 "usage": {"prompt_tokens": 40_000, "completion_tokens": 8}
16957 }),
16958 ),
16959 CacheRoutingSurface::Stream => ResponseTemplate::new(200).set_body_raw(
16960 concat!(
16961 "data: {\"choices\":[{\"delta\":{\"content\":\"cache-route-ok\"},\"finish_reason\":\"stop\"}]}\n\n",
16962 "data: [DONE]\n\n"
16963 ),
16964 "text/event-stream",
16965 ),
16966 };
16967 Mock::given(method("POST"))
16968 .and(path("/v1/chat/completions"))
16969 .respond_with(response)
16970 .mount(&server)
16971 .await;
16972
16973 let tmp = TempDir::new().unwrap();
16974 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16975 let mut uncached_cheap = crate::openrouter::curated_schemas()
16976 .into_iter()
16977 .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
16978 .unwrap();
16979 uncached_cheap.id = "openrouter/test/uncached-cheap".into();
16980 uncached_cheap.name = uncached_cheap.id.clone();
16981 uncached_cheap.cost = CostModel {
16982 input_per_mtok: Some(1.0),
16983 output_per_mtok: Some(1.0),
16984 cache_read_input_per_mtok: Some(100.0),
16985 cache_write_input_per_mtok: Some(100.0),
16986 ..Default::default()
16987 };
16988 if let ModelSource::RemoteApi { endpoint, .. } = &mut uncached_cheap.source {
16989 *endpoint = server.uri();
16990 }
16991
16992 let mut cached_cheap = uncached_cheap.clone();
16993 cached_cheap.id = "openrouter/test/cached-cheap".into();
16994 cached_cheap.name = cached_cheap.id.clone();
16995 cached_cheap.cost = CostModel {
16996 input_per_mtok: Some(80.0),
16997 output_per_mtok: Some(1.0),
16998 cache_read_input_per_mtok: Some(0.001),
16999 cache_write_input_per_mtok: Some(0.001),
17000 ..Default::default()
17001 };
17002
17003 let uncached_id = uncached_cheap.id.clone();
17004 let cached_id = cached_cheap.id.clone();
17005 engine
17006 .unified_registry
17007 .register_project_model(uncached_cheap);
17008 engine.unified_registry.register_project_model(cached_cheap);
17009 let exclude_models = engine
17010 .list_schemas()
17011 .into_iter()
17012 .map(|schema| schema.id)
17013 .filter(|id| id != &uncached_id && id != &cached_id)
17014 .collect();
17015
17016 let mut params = GenerateParams {
17020 max_tokens: 8,
17021 ..Default::default()
17022 };
17023 assert_eq!(params.estimated_cache_read_input_tokens, 0);
17024 assert_eq!(params.estimated_cache_write_input_tokens, 0);
17025 params.estimated_cache_read_input_tokens = cache_read_estimate;
17026 params.estimated_cache_write_input_tokens = cache_write_estimate;
17027 let req = GenerateRequest {
17028 prompt: "x".repeat(160_000),
17029 params,
17030 cache_control: true,
17031 intent: Some(IntentHint {
17032 prefer_quality: true,
17033 exclude_models,
17034 ..Default::default()
17035 }),
17036 ..Default::default()
17037 };
17038
17039 match surface {
17040 CacheRoutingSurface::Generate => {
17041 engine
17042 .generate_tracked(req)
17043 .await
17044 .expect("mocked OpenRouter generation should succeed")
17045 .model_used
17046 }
17047 CacheRoutingSurface::Stream => {
17048 let mut handle = engine
17049 .generate_tracked_stream(req)
17050 .await
17051 .expect("mocked OpenRouter stream should start");
17052 let selected = handle.model_used.clone();
17053 while handle.events.recv().await.is_some() {}
17054 selected
17055 }
17056 }
17057 }
17058
17059 #[tokio::test(flavor = "current_thread")]
17060 async fn tracked_generate_uses_explicit_cache_estimate_and_defaults_to_zero() {
17061 let without_estimate =
17062 invoke_cache_routed_openrouter(CacheRoutingSurface::Generate, 0, 0).await;
17063 let with_estimate =
17064 invoke_cache_routed_openrouter(CacheRoutingSurface::Generate, 40_000, 0).await;
17065 assert_eq!(without_estimate, "openrouter/test/uncached-cheap");
17066 assert_eq!(with_estimate, "openrouter/test/cached-cheap");
17067 }
17068
17069 #[tokio::test(flavor = "current_thread")]
17070 async fn tracked_stream_uses_explicit_cache_estimate_and_defaults_to_zero() {
17071 let without_estimate =
17072 invoke_cache_routed_openrouter(CacheRoutingSurface::Stream, 0, 0).await;
17073 let with_estimate =
17074 invoke_cache_routed_openrouter(CacheRoutingSurface::Stream, 0, 40_000).await;
17075 assert_eq!(without_estimate, "openrouter/test/uncached-cheap");
17076 assert_eq!(with_estimate, "openrouter/test/cached-cheap");
17077 }
17078
17079 #[tokio::test(flavor = "current_thread")]
17080 async fn authenticated_openrouter_registry_stays_static_and_rejects_unknown_ids() {
17081 let _credential_scope = crate::openrouter::test_credential_scope();
17082 crate::openrouter::set_test_credential(Some("static-key"));
17083 let tmp = TempDir::new().unwrap();
17084 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17085 let personal: Vec<_> = engine
17086 .list_schemas()
17087 .into_iter()
17088 .filter(|schema| schema.id.starts_with("openrouter/"))
17089 .collect();
17090 assert_eq!(personal.len(), crate::openrouter::curated_model_count());
17091 assert!(personal.iter().all(ModelSchema::available_now));
17092 assert!(personal
17093 .iter()
17094 .all(|schema| schema.trust_tier == TrustTier::Curated));
17095 assert!(personal
17096 .iter()
17097 .all(|schema| !schema.tags.iter().any(|tag| tag == "dynamic")));
17098
17099 for unknown in [
17100 "openrouter/vendor/brand-new-model",
17101 "openrouter/openai/gpt-5.4-typo",
17102 ] {
17103 assert!(engine
17104 .list_schemas()
17105 .iter()
17106 .all(|schema| schema.id != unknown));
17107 assert_eq!(engine.model_context_window(unknown), 0);
17108 let error = engine
17109 .generate_tracked(GenerateRequest {
17110 prompt: "must fail before transport".into(),
17111 model: Some(unknown.into()),
17112 params: GenerateParams {
17113 strict_model: true,
17114 ..Default::default()
17115 },
17116 ..Default::default()
17117 })
17118 .await
17119 .expect_err("unregistered personal OpenRouter ids must not reach inference");
17120 assert!(
17121 matches!(&error, InferenceError::ModelNotFound(id) if id == unknown),
17122 "{unknown}: {error}"
17123 );
17124 let stream_error = match engine
17125 .generate_tracked_stream(GenerateRequest {
17126 prompt: "must fail before stream transport".into(),
17127 model: Some(unknown.into()),
17128 ..Default::default()
17129 })
17130 .await
17131 {
17132 Ok(_) => panic!("unregistered ids must also fail before streaming"),
17133 Err(error) => error,
17134 };
17135 assert!(
17136 matches!(&stream_error, InferenceError::ModelNotFound(id) if id == unknown),
17137 "{unknown}: {stream_error}"
17138 );
17139 }
17140 }
17141
17142 #[tokio::test(flavor = "current_thread")]
17143 async fn static_openrouter_rows_participate_in_adaptive_routing_only_with_a_key() {
17144 let (_credential_scope, _provider_env) =
17145 crate::openrouter::test_credential_and_environment_scope_async().await;
17146 let tmp = TempDir::new().unwrap();
17147 crate::openrouter::set_test_credential(Some("static-key"));
17148 unsafe {
17149 std::env::set_var("CAR_STATIC_ROUTING_PEER_KEY", "peer-key");
17150 }
17151 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17152 let mut peer = remote_stream_fixture_schema(
17153 "test/openai-routing-peer",
17154 "http://127.0.0.1:9".into(),
17155 schema::ApiProtocol::OpenAiCompat,
17156 "CAR_STATIC_ROUTING_PEER_KEY",
17157 );
17158 peer.provider = "openai".into();
17159 peer.trust_tier = TrustTier::Curated;
17160 engine.unified_registry.register_project_model(peer);
17161 let reviewed: std::collections::HashSet<_> = engine
17162 .list_schemas()
17163 .into_iter()
17164 .filter(|schema| schema.id.starts_with("openrouter/"))
17165 .map(|schema| schema.id)
17166 .collect();
17167 assert_eq!(reviewed.len(), crate::openrouter::curated_model_count());
17168
17169 let with_key = engine
17170 .route_adaptive_with_intent(
17171 "Answer this simple question cheaply.",
17172 Some(IntentHint::default()),
17173 )
17174 .await;
17175 let openrouter_candidates: Vec<_> = std::iter::once(with_key.model_id.as_str())
17176 .chain(
17177 with_key
17178 .candidates
17179 .iter()
17180 .map(|candidate| candidate.model_id.as_str()),
17181 )
17182 .chain(with_key.fallbacks.iter().map(String::as_str))
17183 .filter(|id| id.starts_with("openrouter/"))
17184 .collect();
17185 assert!(
17186 !openrouter_candidates.is_empty(),
17187 "keyed adaptive decision must include a reviewed OpenRouter row: {with_key:?}"
17188 );
17189 assert!(openrouter_candidates
17190 .iter()
17191 .all(|id| reviewed.contains(*id)));
17192 assert!(
17193 std::iter::once(with_key.model_id.as_str())
17194 .chain(with_key.fallbacks.iter().map(String::as_str),)
17195 .any(|id| !id.starts_with("openrouter/")),
17196 "fallback chain must retain cross-provider alternatives: {with_key:?}"
17197 );
17198
17199 crate::openrouter::set_test_credential(None);
17200 let without_key = engine
17201 .route_adaptive_with_intent(
17202 "Answer this simple question cheaply.",
17203 Some(IntentHint::default()),
17204 )
17205 .await;
17206 assert!(!std::iter::once(without_key.model_id.as_str())
17207 .chain(
17208 without_key
17209 .candidates
17210 .iter()
17211 .map(|candidate| candidate.model_id.as_str()),
17212 )
17213 .chain(without_key.fallbacks.iter().map(String::as_str))
17214 .any(|id| id.starts_with("openrouter/")));
17215 unsafe {
17216 std::env::remove_var("CAR_STATIC_ROUTING_PEER_KEY");
17217 }
17218 }
17219
17220 #[tokio::test(flavor = "current_thread")]
17221 async fn v2_parslee_auth_drives_managed_registration_routing_lane_and_logout() {
17222 if !crate::run_in_isolated_test_process(
17223 "tests::v2_parslee_auth_drives_managed_registration_routing_lane_and_logout",
17224 "CAR_V2_ROUTING_AUTH_CHILD",
17225 ) {
17226 return;
17227 }
17228 let tmp = TempDir::new().unwrap();
17229 let (_credential_scope, _provider_env) =
17230 crate::openrouter::test_credential_and_environment_scope_async().await;
17231 let _restore = RestoredEnvironment::capture(&[
17232 "CAR_SECRETS_FILE_DIR",
17233 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
17234 ]);
17235 let _home = crate::openrouter::StateRootScope::new();
17242 crate::openrouter::clear_gateway_unconfigured();
17243 crate::parslee_credential::clear_credential_rejected();
17244 let secrets_dir = tmp.path().join("secrets");
17245 unsafe {
17246 std::env::set_var("CAR_SECRETS_FILE_DIR", &secrets_dir);
17247 std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
17248 }
17249 crate::openrouter::set_test_credential(Some("personal-openrouter-key"));
17250
17251 let store = car_secrets::SecretStore::new();
17252 let state_ref =
17253 car_secrets::SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
17254 store
17255 .publish(
17256 &state_ref,
17257 &serde_json::json!({
17258 "schema": 2,
17259 "revision": 7,
17260 "generation": 3,
17261 "active": {
17262 "account_id": "account-v2",
17263 "access_token": "v2-access",
17264 "refresh_token": "v2-refresh",
17265 "expires_at": 9_999_999_999_u64,
17266 "api_base": "https://api.parslee.ai"
17267 },
17268 "accounts": [{
17269 "account_id": "account-v2",
17270 "access_token": "v2-access",
17271 "refresh_token": "v2-refresh",
17272 "expires_at": 9_999_999_999_u64,
17273 "api_base": "https://api.parslee.ai"
17274 }],
17275 "tombstone": false
17276 })
17277 .to_string(),
17278 )
17279 .unwrap();
17280 assert_eq!(car_auth::access_token().as_deref(), Some("v2-access"));
17281 assert!(
17282 !store
17283 .status(&car_secrets::SecretRef::with_default_service(
17284 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
17285 ))
17286 .unwrap()
17287 .exists,
17288 "the regression must exercise V2 state without the import-only legacy slot"
17289 );
17290
17291 let managed_ids = [
17292 "parslee/openrouter/frontier-general",
17293 "parslee/openrouter/balanced-general",
17294 ];
17295 let personal_id = "openrouter/deepseek/deepseek-v3.2";
17296 let rogue_id = "community/custom-oauth";
17297 let schemas = crate::openrouter::curated_schemas();
17298 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("registry-models"));
17299 for id in managed_ids.into_iter().chain(std::iter::once(personal_id)) {
17300 registry.register_project_model(
17301 schemas
17302 .iter()
17303 .find(|schema| schema.id == id)
17304 .unwrap_or_else(|| panic!("missing curated schema {id}"))
17305 .clone(),
17306 );
17307 }
17308 registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
17309 for id in managed_ids {
17310 assert!(
17311 registry.get(id).unwrap().available_now(),
17312 "{id} must be available immediately when registered from V2 auth"
17313 );
17314 }
17315 assert!(registry.get(personal_id).unwrap().available_now());
17316 let mut rogue = schemas
17317 .iter()
17318 .find(|schema| schema.id == managed_ids[0])
17319 .unwrap()
17320 .clone();
17321 rogue.id = rogue_id.into();
17322 rogue.provider = "community".into();
17323 if let ModelSource::Proprietary {
17324 provider, endpoint, ..
17325 } = &mut rogue.source
17326 {
17327 *provider = "community".into();
17328 *endpoint = "https://untrusted.example".into();
17329 } else {
17330 panic!("managed fixture must remain proprietary");
17331 }
17332 registry.register(rogue);
17333 assert!(
17334 !registry.get(rogue_id).unwrap().available_now(),
17335 "a non-Parslee OAuth schema must not inherit Parslee V2 availability"
17336 );
17337
17338 registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
17339 for id in managed_ids {
17340 assert!(
17341 registry.get(id).unwrap().available_now(),
17342 "{id} must stay available in a refreshed V2-auth snapshot"
17343 );
17344 }
17345 assert!(
17346 !registry.get(rogue_id).unwrap().available_now(),
17347 "refresh must keep non-Parslee OAuth schemas unavailable"
17348 );
17349
17350 let router = AdaptiveRouter::new(
17351 crate::hardware::HardwareInfo::detect(),
17352 RoutingConfig {
17353 prefer_local: false,
17354 prior_strength: 1_000_000.0,
17355 quality_first_cold_start: false,
17356 ..RoutingConfig::default()
17357 },
17358 );
17359 let tracker = OutcomeTracker::new();
17360 let intent = IntentHint {
17361 task: Some(crate::intent::TaskHint::Chat),
17362 exclude_models: vec![personal_id.into()],
17363 ..Default::default()
17364 };
17365 let decision = router.route_with(crate::adaptive_router::RouteRequest {
17366 intent: Some(&intent),
17367 ..crate::adaptive_router::RouteRequest::new(
17368 "Explain this architecture.",
17369 ®istry,
17370 &tracker,
17371 )
17372 });
17373 assert!(
17374 managed_ids.contains(&decision.model_id.as_str()),
17375 "managed V2-auth alias must be selectable: {decision:?}"
17376 );
17377 assert!(
17378 decision
17379 .candidates
17380 .iter()
17381 .any(|candidate| managed_ids.contains(&candidate.model_id.as_str())),
17382 "managed V2-auth alias must appear in adaptive candidates: {decision:?}"
17383 );
17384 assert!(
17385 decision
17386 .fallbacks
17387 .iter()
17388 .any(|id| managed_ids.contains(&id.as_str())),
17389 "managed V2-auth alias must appear in adaptive fallbacks: {decision:?}"
17390 );
17391 assert!(
17392 !std::iter::once(decision.model_id.as_str())
17393 .chain(
17394 decision
17395 .candidates
17396 .iter()
17397 .map(|candidate| candidate.model_id.as_str())
17398 )
17399 .chain(decision.fallbacks.iter().map(String::as_str))
17400 .any(|id| id == rogue_id),
17401 "a non-Parslee OAuth schema must never enter adaptive selection, candidates, or fallbacks: {decision:?}"
17402 );
17403
17404 let engine = InferenceEngine::new(test_config(tmp.path().join("engine-models")));
17405 let managed_lane_id = managed_ids[0];
17406 engine.lane_defaults_cache.write().unwrap().set(
17407 None,
17408 crate::intent::UseCase::Assistant,
17409 managed_lane_id.into(),
17410 1,
17411 );
17412 let request = GenerateRequest {
17413 prompt: "lane default".into(),
17414 intent: Some(IntentHint {
17415 task: Some(crate::intent::TaskHint::Chat),
17416 ..Default::default()
17417 }),
17418 ..Default::default()
17419 };
17420 assert_eq!(
17421 engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
17422 Some(managed_lane_id.to_string()),
17423 "a managed lane default must resolve from authoritative V2 auth"
17424 );
17425
17426 car_auth::logout()
17427 .await
17428 .expect("production logout must publish a signed-out tombstone");
17429 let persisted_logout: serde_json::Value =
17430 serde_json::from_str(&store.get(&state_ref).unwrap()).unwrap();
17431 assert_eq!(persisted_logout["tombstone"], true);
17432 assert_eq!(persisted_logout["accounts"], serde_json::json!([]));
17433 assert!(persisted_logout.get("active").is_none());
17434 assert_eq!(car_auth::access_token(), None);
17435
17436 registry.refresh_routing_availability(None, true);
17437 for id in managed_ids {
17438 assert!(
17439 !registry.get(id).unwrap().available_now(),
17440 "{id} must disappear from routing after the signed-out tombstone"
17441 );
17442 }
17443 assert!(
17444 registry.get(personal_id).unwrap().available_now(),
17445 "personal rows must continue to follow their independent personal-key seam"
17446 );
17447
17448 let signed_out = engine.routing_registry_snapshot().await;
17449 assert!(
17450 signed_out
17451 .list()
17452 .into_iter()
17453 .filter(|schema| schema.id.starts_with("parslee/openrouter/"))
17454 .all(|schema| !schema.available_now()),
17455 "the next engine snapshot must exclude every managed alias after logout"
17456 );
17457 assert!(
17458 signed_out.get(personal_id).unwrap().available_now(),
17459 "logout must not disable a still-keyed personal OpenRouter row"
17460 );
17461 assert_eq!(
17462 engine.lane_pin_for(&request, &signed_out),
17463 None,
17464 "a signed-out managed lane default must stop resolving"
17465 );
17466
17467 crate::openrouter::set_test_credential(None);
17468 registry.refresh_availability();
17469 assert!(
17470 !registry.get(personal_id).unwrap().available_now(),
17471 "personal row availability must still turn off with the personal-key seam"
17472 );
17473 }
17474
17475 #[tokio::test(flavor = "current_thread")]
17476 async fn legacy_parslee_auth_is_routable_only_until_v2_tombstone_then_migrates() {
17477 if !crate::run_in_isolated_test_process(
17478 "tests::legacy_parslee_auth_is_routable_only_until_v2_tombstone_then_migrates",
17479 "CAR_LEGACY_V2_MIGRATION_CHILD",
17480 ) {
17481 return;
17482 }
17483 let tmp = TempDir::new().unwrap();
17484 let _provider_env = crate::openrouter::test_environment_scope_async().await;
17485 let _restore = RestoredEnvironment::capture(&[
17486 "CAR_SECRETS_FILE_DIR",
17487 car_home::ENV_VAR,
17488 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
17489 ]);
17490 let _home = crate::openrouter::StateRootScope::new();
17505 crate::openrouter::clear_gateway_unconfigured();
17506 crate::parslee_credential::clear_credential_rejected();
17507 unsafe {
17508 std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
17509 std::env::set_var(car_home::ENV_VAR, tmp.path().join("car-home"));
17510 }
17511
17512 let secret = |key| car_secrets::SecretRef::with_default_service(key);
17513 let seed_legacy = |store: &car_secrets::SecretStore| {
17514 store
17515 .put(
17516 &secret(car_secrets::PARSLEE_ACCESS_TOKEN_KEY),
17517 "legacy-access",
17518 )
17519 .unwrap();
17520 store
17521 .put(
17522 &secret(car_secrets::PARSLEE_ACTIVE_ACCOUNT_ID_KEY),
17523 "legacy-account",
17524 )
17525 .unwrap();
17526 store.put(
17527 &secret(car_secrets::PARSLEE_ACCOUNTS_KEY),
17528 r#"{"active":"legacy-account","accounts":[{"id":"legacy-account","email":"legacy@example.test"}]}"#,
17529 )
17530 .unwrap();
17531 };
17532 let state_ref = secret(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
17533 let managed = crate::openrouter::curated_schemas()
17534 .into_iter()
17535 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
17536 .unwrap();
17537
17538 unsafe {
17539 std::env::set_var("CAR_SECRETS_FILE_DIR", tmp.path().join("tombstone-secrets"));
17540 }
17541 let tombstoned_store = car_secrets::SecretStore::new();
17542 seed_legacy(&tombstoned_store);
17543 tombstoned_store
17544 .publish(
17545 &state_ref,
17546 r#"{"schema":2,"revision":1,"generation":1,"accounts":[],"tombstone":true}"#,
17547 )
17548 .unwrap();
17549 let mut tombstoned_registry =
17550 UnifiedRegistry::new_empty(tmp.path().join("tombstoned-models"));
17551 tombstoned_registry.register_project_model(managed.clone());
17552 assert!(
17553 !tombstoned_registry
17554 .get(&managed.id)
17555 .unwrap()
17556 .available_now(),
17557 "passive catalog registration must remain disabled without a configured hint"
17558 );
17559 let tombstoned = car_auth::resolve_credential(car_auth::CredentialReadMode::Use)
17560 .await
17561 .unwrap();
17562 assert!(tombstoned.is_none(), "the V2 tombstone is authoritative");
17563 tombstoned_registry.refresh_routing_availability(None, true);
17564 assert!(!tombstoned_registry
17565 .get(&managed.id)
17566 .unwrap()
17567 .available_now());
17568
17569 unsafe {
17570 std::env::set_var(
17571 "CAR_SECRETS_FILE_DIR",
17572 tmp.path().join("legacy-only-secrets"),
17573 );
17574 }
17575 let legacy_store = car_secrets::SecretStore::new();
17576 seed_legacy(&legacy_store);
17577 assert!(!legacy_store.status(&state_ref).unwrap().exists);
17578 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("legacy-models"));
17579 registry.register_project_model(managed.clone());
17580 assert!(
17581 !registry.get(&managed.id).unwrap().available_now(),
17582 "passive registration must not inspect attributable legacy slots"
17583 );
17584 registry.refresh_availability();
17585 assert!(
17586 !registry.get(&managed.id).unwrap().available_now(),
17587 "passive refresh must remain secret-store free until request-time migration"
17588 );
17589 let resolved = car_auth::resolve_credential(car_auth::CredentialReadMode::Use)
17590 .await
17591 .unwrap()
17592 .expect("request-time auth reconciliation must migrate attributable legacy state");
17593 registry.refresh_routing_availability(Some(&resolved.api_base), false);
17594 assert!(registry.get(&managed.id).unwrap().available_now());
17595 assert!(
17596 legacy_store.status(&state_ref).unwrap().exists,
17597 "request-time auth reconciliation must publish the migrated V2 record"
17598 );
17599 assert!(
17600 !legacy_store
17601 .status(&secret(car_secrets::PARSLEE_ACCESS_TOKEN_KEY))
17602 .unwrap()
17603 .exists,
17604 "successful V2 migration must clean the legacy access slot"
17605 );
17606 }
17607
17608 #[tokio::test]
17609 async fn reviewed_openrouter_lane_default_tracks_live_credential_availability() {
17610 let _credential_scope = crate::openrouter::test_credential_scope();
17611 crate::openrouter::set_test_credential(Some("static-key"));
17612 let tmp = TempDir::new().unwrap();
17613 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17614 let reviewed_id = "openrouter/deepseek/deepseek-v3.2";
17615 engine.lane_defaults_cache.write().unwrap().set(
17616 None,
17617 crate::intent::UseCase::Assistant,
17618 reviewed_id.into(),
17619 1,
17620 );
17621 let request = GenerateRequest {
17622 prompt: "lane default".into(),
17623 intent: Some(IntentHint {
17624 task: Some(crate::intent::TaskHint::Chat),
17625 ..Default::default()
17626 }),
17627 ..Default::default()
17628 };
17629 assert_eq!(
17630 engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
17631 Some(reviewed_id.to_string()),
17632 "a reviewed keyed row must be eligible as a lane default"
17633 );
17634 crate::openrouter::set_test_credential(None);
17635 assert_eq!(
17636 engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
17637 None,
17638 "the same static lane default must stop being eligible immediately after key removal"
17639 );
17640 }
17641
17642 #[tokio::test(flavor = "current_thread")]
17643 async fn personal_openrouter_baseline_remains_visible_but_disabled_without_a_credential() {
17644 let _credential_scope = crate::openrouter::test_credential_scope();
17645 crate::openrouter::set_test_credential(None);
17646 let tmp = TempDir::new().unwrap();
17647 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17648
17649 let personal: Vec<_> = engine
17650 .list_schemas()
17651 .into_iter()
17652 .filter(|schema| schema.id.starts_with("openrouter/"))
17653 .collect();
17654 assert_eq!(
17655 personal.len(),
17656 crate::openrouter::curated_model_count(),
17657 "the vetted personal rows stay discoverable as a disabled baseline"
17658 );
17659 assert!(
17660 personal.iter().all(|schema| !schema.available_now()),
17661 "no-key baseline rows must never become routing candidates"
17662 );
17663
17664 let error = engine
17665 .generate_tracked(GenerateRequest {
17666 prompt: "hello".into(),
17667 model: Some("openrouter/openai/gpt-5.4".into()),
17668 params: GenerateParams {
17669 strict_model: true,
17670 ..Default::default()
17671 },
17672 ..Default::default()
17673 })
17674 .await
17675 .expect_err("explicitly selecting a disabled baseline row must be actionable");
17676 let message = error.to_string();
17677 assert!(
17678 message.contains("car keys set openrouter") && message.contains("CarHost"),
17679 "disabled personal row must explain how to connect OpenRouter: {message}"
17680 );
17681 }
17682
17683 #[tokio::test]
17684 async fn reviewed_openrouter_metadata_stays_static_across_credential_changes() {
17685 let _credential_scope = crate::openrouter::test_credential_scope();
17686 crate::openrouter::set_test_credential(Some("static-key"));
17687
17688 let tmp = TempDir::new().unwrap();
17689 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17690 let live = engine.routing_registry_snapshot().await;
17691 let vetted = live.get("openrouter/openai/gpt-5.4").unwrap();
17692 assert!(vetted.available_now());
17693 assert_eq!(vetted.context_length, 1_050_000);
17694 assert_eq!(vetted.max_output_tokens, Some(128_000));
17695 assert_eq!(vetted.cost.input_per_mtok, Some(2.5));
17696 assert_eq!(vetted.cost.output_per_mtok, Some(15.0));
17697 assert!(
17698 !vetted.cost.pricing_tiers.is_empty(),
17699 "reviewed high-context pricing tiers must remain in the static row"
17700 );
17701 assert_eq!(vetted.trust_tier, TrustTier::Curated);
17702 assert!(vetted.tags.iter().any(|tag| tag == "frontier"));
17703 assert!(vetted.has_capability(ModelCapability::Code));
17704 assert!(vetted
17705 .supported_params
17706 .contains(&schema::GenerateParam::ExtendedThinking));
17707
17708 assert!(live.get("openrouter/vendor/unreviewed-model").is_none());
17709
17710 crate::openrouter::set_test_credential(None);
17711 let reverted = engine.routing_registry_snapshot().await;
17712 assert!(reverted.get("openrouter/vendor/unreviewed-model").is_none());
17713 let baseline = reverted.get("openrouter/openai/gpt-5.4").unwrap();
17714 assert!(
17715 !baseline.available_now(),
17716 "credential removal must disable the reviewed row without removing it"
17717 );
17718 }
17719
17720 #[test]
17739 fn model_pulled_at_runtime_is_ready_without_a_daemon_restart() {
17740 let tmp = TempDir::new().unwrap();
17741 let models_dir = tmp.path().join("models");
17742 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
17743 engine.register_model(ModelSchema {
17744 id: "mlx/pulled-later".into(),
17745 name: "PulledLater".into(),
17746 provider: "local".into(),
17747 family: "qwen3".into(),
17748 version: "test".into(),
17749 capabilities: vec![ModelCapability::Generate, ModelCapability::Code],
17750 context_length: 4096,
17751 max_output_tokens: None,
17752 param_count: String::new(),
17753 quantization: None,
17754 performance: schema::PerformanceEnvelope::default(),
17755 cost: schema::CostModel::default(),
17756 source: ModelSource::Mlx {
17757 hf_repo: "example/pulled-later".into(),
17758 hf_weight_file: None,
17759 },
17760 tags: vec![],
17761 supported_params: vec![],
17762 public_benchmarks: vec![],
17763 trust_tier: TrustTier::Curated,
17764 deprecated: false,
17765 available: true,
17766 weights_ready: false,
17767 });
17768
17769 let engine = &engine;
17771 let is_ready = || {
17772 engine
17773 .list_schemas()
17774 .into_iter()
17775 .find(|s| s.id == "mlx/pulled-later")
17776 .expect("registered model must be listed")
17777 .weights_ready
17778 };
17779
17780 assert!(!is_ready(), "precondition: no weights on disk yet");
17781
17782 let dir = models_dir.join("PulledLater");
17785 std::fs::create_dir_all(&dir).unwrap();
17786 std::fs::write(dir.join("config.json"), "{}").unwrap();
17787 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
17788
17789 assert!(
17790 is_ready(),
17791 "a model pulled at runtime must be ready without restarting the daemon"
17792 );
17793
17794 std::fs::remove_file(dir.join("model.safetensors")).unwrap();
17797 assert!(
17798 !is_ready(),
17799 "readiness must track the disk in both directions, not latch"
17800 );
17801 }
17802
17803 #[tokio::test(flavor = "current_thread")]
17804 async fn missing_openrouter_key_is_typed_without_changing_its_string_contract() {
17805 let (_credential_scope, _provider_environment) =
17809 crate::openrouter::test_credential_and_environment_scope_async().await;
17810 let home = crate::openrouter::StateRootScope::new();
17811 let _restore = RestoredEnvironment::capture(&["CAR_SECRETS_FILE_DIR"]);
17812 unsafe {
17813 std::env::set_var("CAR_SECRETS_FILE_DIR", home.path().join("secrets"));
17814 }
17815
17816 crate::openrouter::set_test_credential(None);
17820
17821 let models_dir = home.path().join("models");
17822 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
17823 let schema = crate::openrouter::curated_schemas()
17824 .into_iter()
17825 .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
17826 .expect("personal OpenRouter fixture");
17827 let model_id = schema.id.clone();
17828 engine.unified_registry.register_project_model(schema);
17829
17830 let error = engine
17831 .generate_tracked(GenerateRequest {
17832 prompt: "must stop before provider dispatch".into(),
17833 model: Some(model_id.clone()),
17834 params: GenerateParams {
17835 strict_model: true,
17836 ..Default::default()
17837 },
17838 ..Default::default()
17839 })
17840 .await
17841 .expect_err("an OpenRouter route without a key must fail before dispatch");
17842
17843 let tracker = engine.outcome_tracker();
17844 assert!(
17845 tracker.read().await.profile(&model_id).is_none(),
17846 "a missing OpenRouter key must not create model health"
17847 );
17848 let mut receipts = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
17849 receipts.extend(tracker.write().await.drain_ledger());
17850 let receipts = receipts
17851 .iter()
17852 .filter(|receipt| receipt.model_id == model_id)
17853 .collect::<Vec<_>>();
17854 assert_eq!(receipts.len(), 1);
17855 assert_eq!(receipts[0].success, None);
17856 assert_eq!(receipts[0].quality, None);
17857 assert!(
17858 engine
17859 .adaptive_router
17860 .circuit_breakers
17861 .lock()
17862 .unwrap()
17863 .state(&model_id)
17864 .is_none(),
17865 "a missing OpenRouter key must not create breaker state"
17866 );
17867
17868 let message = "OpenRouter requires a key — run `car keys set openrouter` or connect your OpenRouter account in CarHost";
17869 assert_eq!(
17870 error.to_string(),
17871 format!("inference failed: {message}"),
17872 "guard: the pre-existing Display contract must stay byte-identical"
17873 );
17874 assert!(
17875 !is_auth_failure_message(&error.to_string()),
17876 "guard: an OpenRouter key is not repaired by Parslee sign-in"
17877 );
17878 assert_eq!(
17879 classify_fallback_reason(&error),
17880 classify_fallback_reason(&InferenceError::InferenceFailed(message.into())),
17881 "retyping must preserve prior fallback classification"
17882 );
17883 match error {
17884 InferenceError::ProviderKeyMissing {
17885 provider,
17886 model,
17887 env_vars,
17888 message: actual_message,
17889 } => {
17890 assert_eq!(provider, "openrouter");
17891 assert_eq!(model, model_id);
17892 assert_eq!(env_vars, vec![crate::openrouter::API_KEY_ENV]);
17893 assert_eq!(actual_message, message);
17894 }
17895 other => panic!("expected ProviderKeyMissing, got {other:?}"),
17896 }
17897 }
17898
17899 #[tokio::test(flavor = "current_thread")]
17900 async fn missing_generic_remote_key_is_typed_without_changing_its_string_contract() {
17901 const KEY_ENV: &str = "CAR_TEST_A47_GENERIC_PROVIDER_KEY_DO_NOT_SET";
17902 const EXTRA_KEY_ENV_1: &str = "CAR_TEST_A47_GENERIC_EXTRA_KEY_1_DO_NOT_SET";
17903 const EXTRA_KEY_ENV_2: &str = "CAR_TEST_A47_GENERIC_EXTRA_KEY_2_DO_NOT_SET";
17904 let _provider_environment = crate::openrouter::test_environment_scope_async().await;
17905 let home = crate::openrouter::StateRootScope::new();
17906 let _restore = RestoredEnvironment::capture(&[
17907 "CAR_SECRETS_FILE_DIR",
17908 KEY_ENV,
17909 EXTRA_KEY_ENV_1,
17910 EXTRA_KEY_ENV_2,
17911 ]);
17912 unsafe {
17913 std::env::set_var("CAR_SECRETS_FILE_DIR", home.path().join("secrets"));
17914 std::env::remove_var(KEY_ENV);
17915 std::env::remove_var(EXTRA_KEY_ENV_1);
17916 std::env::remove_var(EXTRA_KEY_ENV_2);
17917 }
17918
17919 let endpoint = "http://127.0.0.1:9/a47-missing-key";
17920 let model_id = "test/a47-generic-missing-key";
17921 let models_dir = home.path().join("models");
17922 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
17923 let mut schema = remote_stream_fixture_schema(
17924 model_id,
17925 endpoint.into(),
17926 schema::ApiProtocol::OpenAiCompat,
17927 KEY_ENV,
17928 );
17929 let ModelSource::RemoteApi { api_key_envs, .. } = &mut schema.source else {
17930 panic!("generic remote fixture must use RemoteApi");
17931 };
17932 *api_key_envs = vec![EXTRA_KEY_ENV_1.into(), EXTRA_KEY_ENV_2.into()];
17933 engine.unified_registry.register_project_model(schema);
17934
17935 let error = engine
17936 .generate_tracked(GenerateRequest {
17937 prompt: "must stop before provider dispatch".into(),
17938 model: Some(model_id.into()),
17939 params: GenerateParams {
17940 strict_model: true,
17941 ..Default::default()
17942 },
17943 ..Default::default()
17944 })
17945 .await
17946 .expect_err("a generic remote route without a key must fail before dispatch");
17947
17948 let tracker = engine.outcome_tracker();
17949 assert!(
17950 tracker.read().await.profile(model_id).is_none(),
17951 "a missing generic provider key must not create model health"
17952 );
17953 let mut receipts = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
17954 receipts.extend(tracker.write().await.drain_ledger());
17955 let receipts = receipts
17956 .iter()
17957 .filter(|receipt| receipt.model_id == model_id)
17958 .collect::<Vec<_>>();
17959 assert_eq!(receipts.len(), 1);
17960 assert_eq!(receipts[0].success, None);
17961 assert_eq!(receipts[0].quality, None);
17962 assert!(
17963 engine
17964 .adaptive_router
17965 .circuit_breakers
17966 .lock()
17967 .unwrap()
17968 .state(model_id)
17969 .is_none(),
17970 "a missing generic provider key must not create breaker state"
17971 );
17972
17973 let message = format!(
17974 "no API keys available for endpoint {endpoint} (checked env vars: [\"{KEY_ENV}\", \"{EXTRA_KEY_ENV_1}\", \"{EXTRA_KEY_ENV_2}\"])"
17975 );
17976 assert_eq!(
17977 error.to_string(),
17978 format!("inference failed: {message}"),
17979 "guard: the pre-existing Display contract must stay byte-identical"
17980 );
17981 assert!(
17982 !is_auth_failure_message(&error.to_string()),
17983 "guard: a generic provider key is not repaired by Parslee sign-in"
17984 );
17985 assert_eq!(
17986 classify_fallback_reason(&error),
17987 classify_fallback_reason(&InferenceError::InferenceFailed(message.clone())),
17988 "retyping must preserve prior fallback classification"
17989 );
17990 match error {
17991 InferenceError::ProviderKeyMissing {
17992 provider,
17993 model,
17994 env_vars,
17995 message: actual_message,
17996 } => {
17997 assert_eq!(provider, "test");
17998 assert_eq!(model, model_id);
17999 assert_eq!(env_vars, vec![KEY_ENV, EXTRA_KEY_ENV_1, EXTRA_KEY_ENV_2]);
18000 assert_eq!(actual_message, message);
18001 }
18002 other => panic!("expected ProviderKeyMissing, got {other:?}"),
18003 }
18004 }
18005
18006 #[test]
18010 fn credential_unavailable_outcomes_do_not_degrade_models_or_open_breakers() {
18011 for reason in [
18012 CredentialFailure::Expired { expires_at: 42 },
18013 CredentialFailure::SignedOut,
18014 CredentialFailure::StoreUnreadable,
18015 CredentialFailure::EnvVarMissing {
18016 env_var: "TEST_PROVIDER_KEY".into(),
18017 },
18018 CredentialFailure::RaceRetryable,
18019 ] {
18020 let model_id = "parslee/test-credential-outcome";
18021 let error = InferenceError::CredentialUnavailable {
18022 provider: "parslee".into(),
18023 model: model_id.into(),
18024 reason: reason.clone(),
18025 detail: "credential resolution stopped before dispatch".into(),
18026 };
18027 let mut tracker = OutcomeTracker::new();
18028 let trace = tracker.record_start(model_id, InferenceTask::Generate, "test");
18029 record_dispatch_failure(&mut tracker, &trace, &error);
18030
18031 assert!(
18032 tracker.profile(model_id).is_none(),
18033 "{reason:?} changed the model profile: {:?}",
18034 tracker.profile(model_id)
18035 );
18036 let receipts = tracker.drain_ledger();
18037 assert_eq!(receipts.len(), 1, "{reason:?}");
18038 assert_eq!(receipts[0].success, None, "{reason:?}");
18039 assert_eq!(receipts[0].quality, None, "{reason:?}");
18040
18041 let mut breakers = crate::routing_ext::CircuitBreakerRegistry::new(1, 60);
18042 if error_counts_against_circuit_breaker(&error) {
18043 breakers.record_failure(model_id);
18044 }
18045 assert!(
18046 breakers.state(model_id).is_none(),
18047 "{reason:?} created per-model breaker state"
18048 );
18049 }
18050 }
18051
18052 #[tokio::test(flavor = "current_thread")]
18053 async fn signed_out_managed_attempts_leave_health_and_breaker_untouched() {
18054 if !crate::run_in_isolated_test_process(
18055 "tests::signed_out_managed_attempts_leave_health_and_breaker_untouched",
18056 "CAR_SIGNED_OUT_MODEL_HEALTH_CHILD",
18057 ) {
18058 return;
18059 }
18060
18061 let _provider_environment = crate::openrouter::test_environment_scope_async().await;
18062 let home = crate::openrouter::StateRootScope::new();
18063 let _restore = RestoredEnvironment::capture(&[
18064 "CAR_SECRETS_FILE_DIR",
18065 car_auth::PARSLEE_ACCESS_TOKEN_KEY,
18066 car_auth::PARSLEE_API_BASE_KEY,
18067 ]);
18068 unsafe {
18069 std::env::set_var("CAR_SECRETS_FILE_DIR", home.path().join("secrets"));
18070 std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
18071 std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
18072 }
18073 crate::openrouter::clear_gateway_unconfigured();
18074 crate::parslee_credential::clear_credential_rejected();
18075
18076 let store = car_secrets::SecretStore::new();
18077 store
18078 .publish(
18079 &car_secrets::SecretRef::with_default_service(
18080 car_secrets::PARSLEE_AUTH_STATE_V2_KEY,
18081 ),
18082 r#"{"schema":2,"revision":1,"generation":1,"accounts":[],"tombstone":true}"#,
18083 )
18084 .unwrap();
18085
18086 let models_dir = home.path().join("models");
18087 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
18088 let schema = crate::openrouter::curated_schemas()
18089 .into_iter()
18090 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
18091 .expect("managed alias fixture");
18092 let model_id = schema.id.clone();
18093 engine.unified_registry.register_project_model(schema);
18094
18095 for _ in 0..3 {
18099 let error = engine
18100 .generate_tracked(GenerateRequest {
18101 prompt: "must stop before provider dispatch".into(),
18102 model: Some(model_id.clone()),
18103 params: GenerateParams {
18104 strict_model: true,
18105 ..Default::default()
18106 },
18107 ..Default::default()
18108 })
18109 .await
18110 .expect_err("signed-out managed inference must be rejected");
18111 assert!(
18112 matches!(
18113 &error,
18114 InferenceError::CredentialUnavailable {
18115 reason: CredentialFailure::SignedOut,
18116 ..
18117 }
18118 ),
18119 "unexpected signed-out error: {error:?}"
18120 );
18121 }
18122
18123 let tracker = engine.outcome_tracker();
18124 assert!(
18125 tracker.read().await.profile(&model_id).is_none(),
18126 "signed-out attempts must not create a model-health profile"
18127 );
18128 let mut receipts = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
18132 receipts.extend(tracker.write().await.drain_ledger());
18133 let signed_out_receipts = receipts
18134 .iter()
18135 .filter(|entry| entry.model_id == model_id)
18136 .collect::<Vec<_>>();
18137 assert_eq!(signed_out_receipts.len(), 3);
18138 assert!(signed_out_receipts
18139 .iter()
18140 .all(|entry| entry.success.is_none() && entry.quality.is_none()));
18141 assert!(
18142 engine
18143 .adaptive_router
18144 .circuit_breakers
18145 .lock()
18146 .unwrap()
18147 .state(&model_id)
18148 .is_none(),
18149 "signed-out attempts must not create per-model breaker state"
18150 );
18151 }
18152
18153 #[test]
18154 fn dispatch_outcome_guards_keep_real_failures_and_existing_rejections_distinct() {
18155 let model_id = "test/model";
18156
18157 let real_failure = InferenceError::InferenceFailed("provider returned HTTP 500".into());
18158 let mut tracker = OutcomeTracker::new();
18159 let trace = tracker.record_start(model_id, InferenceTask::Generate, "test");
18160 record_dispatch_failure(&mut tracker, &trace, &real_failure);
18161 let profile = tracker.profile(model_id).expect("real failure profile");
18162 assert_eq!(profile.success_count, 0);
18163 assert_eq!(profile.fail_count, 1);
18164 let receipt = tracker.drain_ledger().pop().unwrap();
18165 assert_eq!(receipt.success, Some(false));
18166 assert!(error_counts_against_circuit_breaker(&real_failure));
18167 let mut breakers = crate::routing_ext::CircuitBreakerRegistry::new(1, 60);
18168 breakers.record_failure(model_id);
18169 assert_eq!(
18170 breakers.state(model_id),
18171 Some(crate::routing_ext::CircuitState::Open)
18172 );
18173
18174 for rejection in [
18175 InferenceError::GatewayUnconfigured {
18176 provider: "parslee".into(),
18177 namespace: "parslee/openrouter/".into(),
18178 status: 503,
18179 message: "not configured".into(),
18180 },
18181 InferenceError::ContentRefused {
18182 provider: "parslee".into(),
18183 kind: Some("invalid_request_error".into()),
18184 code: Some("content_policy_violation".into()),
18185 message: "content refused".into(),
18186 },
18187 ] {
18188 let mut tracker = OutcomeTracker::new();
18189 let trace = tracker.record_start(model_id, InferenceTask::Generate, "test");
18190 record_dispatch_failure(&mut tracker, &trace, &rejection);
18191 assert!(
18192 tracker.profile(model_id).is_none(),
18193 "existing rejection changed model profile: {rejection}"
18194 );
18195 let receipt = tracker.drain_ledger().pop().unwrap();
18196 assert_eq!(receipt.success, None, "{rejection}");
18197 assert_eq!(receipt.quality, None, "{rejection}");
18198 assert!(!error_counts_against_circuit_breaker(&rejection));
18199 }
18200 }
18201
18202 #[tokio::test(flavor = "current_thread")]
18215 async fn openrouter_out_of_credits_does_not_degrade_the_model() {
18216 use wiremock::matchers::{method, path};
18217 use wiremock::{Mock, MockServer, ResponseTemplate};
18218
18219 let _credential_scope = crate::openrouter::test_credential_scope();
18220 crate::openrouter::set_test_credential(Some("test-openrouter-key"));
18221 let server = MockServer::start().await;
18222 Mock::given(method("POST"))
18223 .and(path("/v1/chat/completions"))
18224 .respond_with(
18225 ResponseTemplate::new(402)
18226 .set_body_string(r#"{"error":{"code":402,"message":"Insufficient credits"}}"#),
18227 )
18228 .mount(&server)
18229 .await;
18230
18231 let tmp = TempDir::new().unwrap();
18232 let models_dir = tmp.path().join("models");
18233 let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
18234 let mut schema = crate::openrouter::curated_schemas()
18235 .into_iter()
18236 .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
18237 .unwrap();
18238 if let ModelSource::RemoteApi { endpoint, .. } = &mut schema.source {
18239 *endpoint = server.uri();
18240 }
18241 let model_id = schema.id.clone();
18242 engine.unified_registry.register_project_model(schema);
18243
18244 let err = engine
18245 .generate_tracked(GenerateRequest {
18246 prompt: "bill me".into(),
18247 model: Some(model_id.clone()),
18248 params: GenerateParams {
18249 strict_model: true,
18250 ..Default::default()
18251 },
18252 ..Default::default()
18253 })
18254 .await
18255 .expect_err("402 must not succeed");
18256
18257 match &err {
18258 InferenceError::ProviderAccount {
18259 provider, status, ..
18260 } => {
18261 assert_eq!(status, &402);
18262 assert_eq!(provider, "openrouter");
18263 }
18264 other => panic!("expected ProviderAccount, got {other:?}"),
18265 }
18266 assert!(
18267 !error_counts_against_circuit_breaker(&err),
18268 "an account rejection must not feed the per-model breaker"
18269 );
18270
18271 let tracker_handle = engine.outcome_tracker();
18273 let tracker = tracker_handle.read().await;
18274 let profile = tracker.profile(&model_id).cloned();
18275 assert!(
18276 profile.as_ref().is_none_or(|p| p.fail_count == 0),
18277 "account rejection degraded the model profile: {profile:?}"
18278 );
18279 drop(tracker);
18280
18281 let ledger = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
18285 let entry = ledger
18286 .iter()
18287 .find(|e| e.model_id == model_id)
18288 .expect("the attempt must still produce a receipt");
18289 assert!(
18290 entry.success.is_none() && entry.quality.is_none(),
18291 "receipt must record the attempt without a verdict: {entry:?}"
18292 );
18293
18294 assert!(
18296 engine
18297 .adaptive_router
18298 .circuit_breakers
18299 .lock()
18300 .unwrap()
18301 .state(&model_id)
18302 .is_none(),
18303 "account rejection must not create breaker state for the model"
18304 );
18305 }
18306
18307 #[tokio::test(flavor = "current_thread")]
18308 async fn openrouter_stream_error_records_failure_and_never_completes_successfully() {
18309 use wiremock::matchers::{method, path};
18310 use wiremock::{Mock, MockServer, ResponseTemplate};
18311
18312 let _credential_scope = crate::openrouter::test_credential_scope();
18313 crate::openrouter::set_test_credential(Some("test-openrouter-key"));
18314 let server = MockServer::start().await;
18315 Mock::given(method("POST"))
18316 .and(path("/v1/chat/completions"))
18317 .respond_with(ResponseTemplate::new(200).set_body_raw(
18318 "data: {\"error\":{\"code\":402,\"message\":\"private balance details\"}}\n\n",
18319 "text/event-stream",
18320 ))
18321 .mount(&server)
18322 .await;
18323
18324 let tmp = TempDir::new().unwrap();
18325 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18326 let mut schema = crate::openrouter::curated_schemas()
18327 .into_iter()
18328 .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
18329 .unwrap();
18330 if let ModelSource::RemoteApi { endpoint, .. } = &mut schema.source {
18331 *endpoint = server.uri();
18332 }
18333 let model_id = schema.id.clone();
18334 engine.unified_registry.register_project_model(schema);
18335
18336 let mut handle = engine
18337 .generate_tracked_stream(GenerateRequest {
18338 prompt: "fail after headers".into(),
18339 model: Some(model_id.clone()),
18340 ..Default::default()
18341 })
18342 .await
18343 .unwrap();
18344 let mut events = Vec::new();
18345 while let Some(event) = handle.events.recv().await {
18346 events.push(event);
18347 }
18348 assert!(matches!(
18349 events.as_slice(),
18350 [StreamEvent::Error(message)] if message == "OpenRouter account is out of credits"
18351 ));
18352 assert!(!events
18353 .iter()
18354 .any(|event| matches!(event, StreamEvent::Done { .. })));
18355
18356 for _ in 0..50 {
18357 if engine
18358 .outcome_tracker()
18359 .read()
18360 .await
18361 .profile(&model_id)
18362 .is_some_and(|profile| profile.fail_count == 1)
18363 {
18364 break;
18365 }
18366 tokio::task::yield_now().await;
18367 }
18368 let tracker = engine.outcome_tracker();
18369 let profile = tracker.read().await.profile(&model_id).cloned().unwrap();
18370 assert_eq!(profile.fail_count, 1);
18371 assert_eq!(profile.success_count, 0);
18372 }
18373
18374 #[tokio::test(flavor = "current_thread")]
18375 async fn tracked_stream_without_done_records_failure_not_success() {
18376 let _offload_guard = crate::offload::test_offload_lock().lock().await;
18377 let tmp = TempDir::new().unwrap();
18378 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18379 pin_test_local_admission_memory(&mut engine);
18380 let model_id = install_small_local_fixture(&engine);
18381 crate::offload::set_local_offload(Some(Arc::new(FixtureLocalOffload { emit_done: false })));
18382
18383 let mut stream = engine
18384 .generate_tracked_stream(GenerateRequest {
18385 prompt: "must not count as success".into(),
18386 model: Some(model_id.clone()),
18387 params: GenerateParams {
18388 strict_model: true,
18389 ..Default::default()
18390 },
18391 ..Default::default()
18392 })
18393 .await
18394 .unwrap();
18395 while stream.events.recv().await.is_some() {}
18396 crate::offload::set_local_offload(None);
18397
18398 for _ in 0..50 {
18399 if engine
18400 .outcome_tracker()
18401 .read()
18402 .await
18403 .profile(&model_id)
18404 .is_some_and(|profile| profile.fail_count == 1)
18405 {
18406 break;
18407 }
18408 tokio::task::yield_now().await;
18409 }
18410 let profile = engine
18411 .outcome_tracker()
18412 .read()
18413 .await
18414 .profile(&model_id)
18415 .cloned()
18416 .unwrap();
18417 assert_eq!(profile.fail_count, 1);
18418 assert_eq!(profile.success_count, 0);
18419 }
18420
18421 #[tokio::test(flavor = "current_thread")]
18422 async fn google_vertex_terminal_matrix_records_only_deliberate_finishes_as_success() {
18423 use wiremock::matchers::{method, path};
18424 use wiremock::{Mock, MockServer, ResponseTemplate};
18425
18426 let _provider_env = crate::openrouter::test_environment_scope_async().await;
18427 let _env = ENV_MUTEX.lock().await;
18428 unsafe { std::env::set_var("CAR_GOOGLE_OUTCOME_MATRIX_KEY", "matrix-key") };
18429
18430 for (protocol, reason, should_succeed) in [
18431 (schema::ApiProtocol::Google, "STOP", true),
18432 (schema::ApiProtocol::Google, "SAFETY", false),
18433 (schema::ApiProtocol::VertexAi, "MAX_TOKENS", true),
18434 (
18435 schema::ApiProtocol::VertexAi,
18436 "MALFORMED_FUNCTION_CALL",
18437 false,
18438 ),
18439 ] {
18440 let server = MockServer::start().await;
18441 let expected_path = match protocol {
18442 schema::ApiProtocol::Google => "/v1beta/models/gemini-test:streamGenerateContent",
18443 schema::ApiProtocol::VertexAi => {
18444 "/publishers/google/models/gemini-test:streamGenerateContent"
18445 }
18446 _ => unreachable!(),
18447 };
18448 Mock::given(method("POST"))
18449 .and(path(expected_path))
18450 .respond_with(ResponseTemplate::new(200).set_body_raw(
18451 format!(
18452 "data: {{\"candidates\":[{{\"content\":{{\"parts\":[{{\"text\":\"matrix\"}}]}},\"finishReason\":\"{reason}\"}}]}}\n\n"
18453 ),
18454 "text/event-stream",
18455 ))
18456 .mount(&server)
18457 .await;
18458
18459 let id = format!("test/{protocol:?}-{reason}");
18460 let tmp = TempDir::new().unwrap();
18461 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18462 engine.register_model(remote_stream_fixture_schema(
18463 &id,
18464 server.uri(),
18465 protocol,
18466 "CAR_GOOGLE_OUTCOME_MATRIX_KEY",
18467 ));
18468 let mut stream = engine
18469 .generate_tracked_stream(GenerateRequest {
18470 prompt: "matrix".into(),
18471 model: Some(id.clone()),
18472 params: GenerateParams {
18473 strict_model: true,
18474 ..Default::default()
18475 },
18476 ..Default::default()
18477 })
18478 .await
18479 .unwrap();
18480 let mut events = Vec::new();
18481 while let Some(event) = stream.events.recv().await {
18482 events.push(event);
18483 }
18484 for _ in 0..50 {
18485 if engine
18486 .outcome_tracker()
18487 .read()
18488 .await
18489 .profile(&id)
18490 .is_some_and(|profile| profile.total_calls == 1)
18491 {
18492 break;
18493 }
18494 tokio::task::yield_now().await;
18495 }
18496 let profile = engine
18497 .outcome_tracker()
18498 .read()
18499 .await
18500 .profile(&id)
18501 .cloned()
18502 .unwrap();
18503 assert_eq!(
18504 (profile.success_count, profile.fail_count),
18505 if should_succeed { (1, 0) } else { (0, 1) },
18506 "{protocol:?}/{reason}: {events:?}"
18507 );
18508 }
18509
18510 unsafe { std::env::remove_var("CAR_GOOGLE_OUTCOME_MATRIX_KEY") };
18511 }
18512
18513 #[tokio::test(flavor = "current_thread")]
18514 async fn remote_primary_stream_setup_failure_falls_back_to_installed_local_dispatch() {
18515 use wiremock::matchers::{method, path};
18516 use wiremock::{Mock, MockServer, ResponseTemplate};
18517
18518 let _offload_guard = crate::offload::test_offload_lock().lock().await;
18519 let _env = ENV_MUTEX.lock().await;
18520 let server = MockServer::start().await;
18521 Mock::given(method("POST"))
18522 .and(path("/v1/chat/completions"))
18523 .respond_with(ResponseTemplate::new(503))
18524 .mount(&server)
18525 .await;
18526 unsafe { std::env::set_var("CAR_STREAM_FALLBACK_TEST_KEY", "fixture") };
18527
18528 let tmp = TempDir::new().unwrap();
18529 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18530 pin_test_local_admission_memory(&mut engine);
18531 let _local_id = install_small_local_fixture(&engine);
18532 let remote_id = "test/remote-primary";
18533 engine.register_model(ModelSchema {
18534 id: remote_id.into(),
18535 name: "remote-primary".into(),
18536 provider: "test".into(),
18537 family: "test".into(),
18538 version: "1".into(),
18539 capabilities: vec![ModelCapability::Generate],
18540 context_length: 8_192,
18541 max_output_tokens: Some(1_024),
18542 param_count: String::new(),
18543 quantization: None,
18544 performance: Default::default(),
18545 cost: Default::default(),
18546 source: ModelSource::RemoteApi {
18547 endpoint: server.uri(),
18548 api_key_env: "CAR_STREAM_FALLBACK_TEST_KEY".into(),
18549 api_key_envs: vec![],
18550 api_version: None,
18551 protocol: schema::ApiProtocol::OpenAiCompat,
18552 },
18553 tags: vec![],
18554 supported_params: vec![],
18555 public_benchmarks: vec![],
18556 trust_tier: TrustTier::Community,
18557 deprecated: false,
18558 available: true,
18559 weights_ready: true,
18560 });
18561 crate::offload::set_local_offload(Some(Arc::new(FixtureLocalOffload { emit_done: true })));
18562
18563 let mut stream = engine
18564 .generate_tracked_stream(GenerateRequest {
18565 prompt: "fall back".into(),
18566 model: Some(remote_id.into()),
18567 ..Default::default()
18568 })
18569 .await
18570 .expect("compatible installed local model should be dispatched");
18571 assert_ne!(stream.model_used, remote_id);
18572 assert!(
18573 engine
18574 .unified_registry
18575 .get(&stream.model_used)
18576 .is_some_and(ModelSchema::is_local),
18577 "fallback must stay on a compatible local model: {}",
18578 stream.model_used
18579 );
18580 let mut saw_done = false;
18581 while let Some(event) = stream.events.recv().await {
18582 saw_done |= matches!(event, StreamEvent::Done { .. });
18583 }
18584 assert!(saw_done);
18585
18586 crate::offload::set_local_offload(None);
18587 unsafe { std::env::remove_var("CAR_STREAM_FALLBACK_TEST_KEY") };
18588 }
18589
18590 #[tokio::test(flavor = "current_thread")]
18591 async fn exact_model_id_rejects_display_name_without_dispatch() {
18592 let tmp = TempDir::new().unwrap();
18593 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18594 let mut first = remote_stream_fixture_schema(
18595 "test/ambiguous-a:1",
18596 "http://127.0.0.1:9".into(),
18597 schema::ApiProtocol::OpenAiCompat,
18598 "CAR_AMBIGUOUS_EXACT_KEY",
18599 );
18600 first.name = "Shared Alias".into();
18601 let mut second = first.clone();
18602 second.id = "test/ambiguous-b:1".into();
18603 engine.register_model(first);
18604 engine.register_model(second);
18605 let mut request = GenerateRequest {
18606 prompt: "must not dispatch".into(),
18607 ..Default::default()
18608 };
18609 pin_exact_model_id(&mut request, "Shared Alias".into()).unwrap();
18610
18611 let error = engine
18612 .generate_tracked(request)
18613 .await
18614 .expect_err("an exact-id pin must never resolve a display name");
18615 assert!(matches!(error, InferenceError::ModelNotFound(_)));
18616 }
18617
18618 #[tokio::test(flavor = "current_thread")]
18619 async fn exact_openai_pin_reports_catalog_id_and_loose_request_keeps_provider_name() {
18620 let _env = ENV_MUTEX.lock().await;
18621 assert_remote_model_identity_contract(
18622 schema::ApiProtocol::OpenAiCompat,
18623 "openai/newsroom-gpt-5.5-2026-04-23:test",
18624 "newsroom-gpt-5.5-2026-04-23-test",
18625 "CAR_OPENAI_IDENTITY_TEST_KEY",
18626 )
18627 .await;
18628 }
18629
18630 #[tokio::test(flavor = "current_thread")]
18631 async fn exact_anthropic_pin_reports_catalog_id_and_loose_request_keeps_provider_name() {
18632 let _env = ENV_MUTEX.lock().await;
18633 assert_remote_model_identity_contract(
18634 schema::ApiProtocol::Anthropic,
18635 "anthropic/newsroom-claude-opus-4-8:test",
18636 "newsroom-claude-opus-4-8-test",
18637 "CAR_ANTHROPIC_IDENTITY_TEST_KEY",
18638 )
18639 .await;
18640 }
18641
18642 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
18643 #[tokio::test(flavor = "current_thread")]
18644 async fn exact_model_id_nonstream_bypasses_mlx_equivalent_substitution() {
18645 let _offload_guard = crate::offload::test_offload_lock().lock().await;
18646 let tmp = TempDir::new().unwrap();
18647 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18648 let exact_id = install_exact_pin_equivalent_fixture(&engine);
18649 let offload = Arc::new(ExactPinCaptureOffload::new());
18650 crate::offload::set_local_offload(Some(offload.clone()));
18651
18652 let mut request = GenerateRequest {
18653 prompt: "use only the immutable row I selected".into(),
18654 ..Default::default()
18655 };
18656 pin_exact_model_id(&mut request, exact_id.clone()).unwrap();
18657 let error = engine.generate_tracked(request).await.unwrap_err();
18658
18659 assert!(offload.dispatched_models().is_empty());
18660 assert!(error.to_string().contains(&exact_id), "{error}");
18661 assert!(
18662 error
18663 .to_string()
18664 .contains("MLX-equivalent substitution is disabled for exact pins"),
18665 "{error}"
18666 );
18667 crate::offload::set_local_offload(None);
18668 }
18669
18670 #[tokio::test(flavor = "current_thread")]
18671 async fn catalog_identity_mismatch_rejects_before_provider_dispatch() {
18672 use wiremock::matchers::{method, path};
18673 use wiremock::{Mock, MockServer, ResponseTemplate};
18674
18675 let _env = ENV_MUTEX.lock().await;
18676 let server = MockServer::start().await;
18677 Mock::given(method("POST"))
18678 .and(path("/v1/chat/completions"))
18679 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
18680 "choices": [{"message": {"content": "must not run"}, "finish_reason": "stop"}],
18681 })))
18682 .mount(&server)
18683 .await;
18684 unsafe { std::env::set_var("CAR_PRECONDITION_TEST_KEY", "fixture") };
18685
18686 let tmp = TempDir::new().unwrap();
18687 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18688 let model_id = "test/catalog-precondition:1";
18689 engine.register_model(remote_stream_fixture_schema(
18690 model_id,
18691 server.uri(),
18692 schema::ApiProtocol::OpenAiCompat,
18693 "CAR_PRECONDITION_TEST_KEY",
18694 ));
18695 let snapshot = engine.catalog_snapshot().unwrap();
18696 let row_digest = snapshot
18697 .model_by_exact_id(model_id)
18698 .unwrap()
18699 .row_digest
18700 .clone();
18701
18702 for (expected_row_digest, expected_catalog_revision) in [
18703 (
18704 Some("0".repeat(64)),
18705 Some(snapshot.catalog_revision.clone()),
18706 ),
18707 (Some(row_digest.clone()), Some("f".repeat(64))),
18708 ] {
18709 let mut request = GenerateRequest {
18710 prompt: "must not dispatch".into(),
18711 expected_row_digest,
18712 expected_catalog_revision,
18713 ..Default::default()
18714 };
18715 pin_exact_model_id(&mut request, model_id.into()).unwrap();
18716 let error = engine.generate_tracked(request).await.unwrap_err();
18717 assert!(matches!(
18718 error,
18719 InferenceError::CatalogPreconditionMismatch { .. }
18720 ));
18721 }
18722 assert!(
18723 server.received_requests().await.unwrap().is_empty(),
18724 "catalog identity mismatches must fail before provider dispatch"
18725 );
18726
18727 unsafe { std::env::remove_var("CAR_PRECONDITION_TEST_KEY") };
18728 }
18729
18730 #[tokio::test(flavor = "current_thread")]
18731 async fn exact_nonstream_identity_survives_thinking_retry() {
18732 use std::sync::atomic::{AtomicUsize, Ordering};
18733 use wiremock::matchers::{method, path};
18734 use wiremock::{Mock, MockServer, ResponseTemplate};
18735
18736 let _env = ENV_MUTEX.lock().await;
18737 let server = MockServer::start().await;
18738 let calls = Arc::new(AtomicUsize::new(0));
18739 let response_calls = calls.clone();
18740 Mock::given(method("POST"))
18741 .and(path("/v1/chat/completions"))
18742 .respond_with(move |_request: &wiremock::Request| {
18743 let attempt = response_calls.fetch_add(1, Ordering::SeqCst);
18744 let content = if attempt == 0 { "" } else { "recovered" };
18745 ResponseTemplate::new(200).set_body_json(serde_json::json!({
18746 "choices": [{
18747 "message": {"content": content},
18748 "finish_reason": if attempt == 0 { "length" } else { "stop" },
18749 }],
18750 "usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
18751 }))
18752 })
18753 .mount(&server)
18754 .await;
18755 unsafe { std::env::set_var("CAR_IDENTITY_RETRY_KEY", "fixture") };
18756
18757 let tmp = TempDir::new().unwrap();
18758 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18759 let model_id = "test/identity-retry:1";
18760 engine.register_model(remote_stream_fixture_schema(
18761 model_id,
18762 server.uri(),
18763 schema::ApiProtocol::OpenAiCompat,
18764 "CAR_IDENTITY_RETRY_KEY",
18765 ));
18766 let snapshot = engine.catalog_snapshot().unwrap();
18767 let expected_row = snapshot.model_by_exact_id(model_id).unwrap();
18768 let mut request = GenerateRequest {
18769 prompt: "retry once".into(),
18770 expected_row_digest: Some(expected_row.row_digest.clone()),
18771 expected_catalog_revision: Some(snapshot.catalog_revision.clone()),
18772 ..Default::default()
18773 };
18774 pin_exact_model_id(&mut request, model_id.into()).unwrap();
18775
18776 let result = engine.generate_tracked(request).await.unwrap();
18777 assert_eq!(calls.load(Ordering::SeqCst), 2, "must exercise retry path");
18778 assert_eq!(result.stop_reason.as_deref(), Some("thinking_recovered"));
18779 assert_eq!(
18780 result.model_identity.requested_model_id.as_deref(),
18781 Some(model_id)
18782 );
18783 assert_eq!(result.model_identity.resolved_model_id, model_id);
18784 assert_eq!(result.model_identity.row_digest, expected_row.row_digest);
18785 assert_eq!(
18786 result.model_identity.catalog_revision,
18787 snapshot.catalog_revision
18788 );
18789
18790 unsafe { std::env::remove_var("CAR_IDENTITY_RETRY_KEY") };
18791 }
18792
18793 #[tokio::test(flavor = "current_thread")]
18794 async fn managed_openrouter_reasoning_items_roundtrip_verbatim_across_two_turns() {
18795 if !crate::run_in_isolated_test_process(
18796 "tests::managed_openrouter_reasoning_items_roundtrip_verbatim_across_two_turns",
18797 "CAR_MANAGED_REASONING_ROUNDTRIP_CHILD",
18798 ) {
18799 return;
18800 }
18801 let _home = crate::openrouter::StateRootScope::new();
18802 use wiremock::matchers::{header, method, path};
18803 use wiremock::{Mock, MockServer, ResponseTemplate};
18804
18805 let _provider_env = crate::openrouter::test_environment_scope_async().await;
18806 let _env = ENV_MUTEX.lock().await;
18807 let bearer = "managed-reasoning-roundtrip-bearer";
18808
18809 let server = MockServer::start().await;
18810 unsafe {
18811 std::env::set_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV, bearer);
18812 std::env::set_var(car_auth::PARSLEE_API_BASE_KEY, server.uri());
18813 }
18814 Mock::given(method("GET"))
18815 .and(path("/api/v1/organizations/me"))
18816 .and(header("authorization", format!("Bearer {bearer}")))
18817 .respond_with(
18818 ResponseTemplate::new(200)
18819 .set_body_json(serde_json::json!({"organizationId": "org-roundtrip"})),
18820 )
18821 .mount(&server)
18822 .await;
18823 Mock::given(method("GET"))
18824 .and(path("/connect/session"))
18825 .respond_with(
18826 ResponseTemplate::new(200)
18827 .set_body_json(serde_json::json!({"account": {"email": "user@example.test"}})),
18828 )
18829 .mount(&server)
18830 .await;
18831 Mock::given(method("POST"))
18832 .and(path("/api/v1/orgs/org-roundtrip/inference/responses"))
18833 .respond_with(ResponseTemplate::new(200).set_body_raw(
18834 include_str!("../tests/fixtures/parslee-openrouter-reasoning-roundtrip.sse"),
18835 "text/event-stream",
18836 ))
18837 .expect(2)
18838 .mount(&server)
18839 .await;
18840
18841 let tmp = TempDir::new().unwrap();
18842 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18843 let schema = crate::openrouter::curated_schemas()
18844 .into_iter()
18845 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
18846 .unwrap();
18847 engine.unified_registry.register_project_model(schema);
18848
18849 let first = engine
18850 .generate_tracked(GenerateRequest {
18851 prompt: "first".into(),
18852 model: Some("parslee/openrouter/frontier-general".into()),
18853 ..Default::default()
18854 })
18855 .await
18856 .expect("first managed turn");
18857 let expected_reasoning = serde_json::json!({
18858 "type": "reasoning",
18859 "id": "rs_car_roundtrip",
18860 "status": "completed",
18861 "summary": [{"type": "summary_text", "text": "safe summary"}],
18862 "encrypted_content": "opaque-encrypted-reasoning",
18863 });
18864 assert_eq!(
18865 first.provider_output_items,
18866 vec![expected_reasoning.clone()]
18867 );
18868
18869 let mut history = vec![Message::User {
18870 content: "first".into(),
18871 }];
18872 first.append_assistant_history(&mut history, first.tool_calls.clone());
18873 history.push(Message::User {
18874 content: "continue".into(),
18875 });
18876
18877 let second = engine
18878 .generate_tracked(GenerateRequest {
18879 prompt: String::new(),
18880 model: Some("parslee/openrouter/frontier-general".into()),
18881 messages: Some(history),
18882 ..Default::default()
18883 })
18884 .await
18885 .expect("second managed turn");
18886 assert_eq!(second.text, "first answer");
18887
18888 let requests = server.received_requests().await.unwrap();
18889 let posts: Vec<serde_json::Value> = requests
18890 .iter()
18891 .filter(|request| {
18892 request.method.as_str() == "POST"
18893 && request.url.path() == "/api/v1/orgs/org-roundtrip/inference/responses"
18894 })
18895 .map(|request| serde_json::from_slice(&request.body).unwrap())
18896 .collect();
18897 assert_eq!(posts.len(), 2);
18898 for body in &posts {
18899 assert_eq!(body["store"], false);
18900 assert_eq!(
18901 body["include"],
18902 serde_json::json!(["reasoning.encrypted_content"])
18903 );
18904 }
18905 let second_input = posts[1]["input"].as_array().unwrap();
18906 let reasoning_index = second_input
18907 .iter()
18908 .position(|item| item == &expected_reasoning)
18909 .expect("second request must replay the exact reasoning item");
18910 let assistant_index = second_input
18911 .iter()
18912 .position(|item| item["role"] == "assistant")
18913 .expect("second request assistant turn");
18914 let user_index = second_input
18915 .iter()
18916 .position(|item| item["content"] == "continue")
18917 .expect("second request user turn");
18918 assert!(
18919 reasoning_index < assistant_index && assistant_index < user_index,
18920 "provider output order must be reasoning, assistant text, then the next user turn"
18921 );
18922
18923 unsafe {
18924 std::env::remove_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV);
18925 std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
18926 }
18927 }
18928
18929 #[tokio::test(flavor = "current_thread")]
18930 async fn managed_partial_eof_fails_buffered_and_streamed_turns_and_records_only_failures() {
18931 if !crate::run_in_isolated_test_process(
18932 "tests::managed_partial_eof_fails_buffered_and_streamed_turns_and_records_only_failures",
18933 "CAR_MANAGED_PARTIAL_EOF_CHILD",
18934 ) {
18935 return;
18936 }
18937 let _home = crate::openrouter::StateRootScope::new();
18938 use wiremock::matchers::{header, method, path};
18939 use wiremock::{Mock, MockServer, ResponseTemplate};
18940
18941 let _provider_env = crate::openrouter::test_environment_scope_async().await;
18942 let _env = ENV_MUTEX.lock().await;
18943 let bearer = "managed-partial-outcome-bearer";
18944
18945 let server = MockServer::start().await;
18946 unsafe {
18947 std::env::set_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV, bearer);
18948 std::env::set_var(car_auth::PARSLEE_API_BASE_KEY, server.uri());
18949 }
18950 Mock::given(method("GET"))
18951 .and(path("/api/v1/organizations/me"))
18952 .and(header("authorization", format!("Bearer {bearer}")))
18953 .respond_with(
18954 ResponseTemplate::new(200)
18955 .set_body_json(serde_json::json!({"organizationId": "org-partial-outcome"})),
18956 )
18957 .mount(&server)
18958 .await;
18959 Mock::given(method("GET"))
18960 .and(path("/connect/session"))
18961 .respond_with(
18962 ResponseTemplate::new(200)
18963 .set_body_json(serde_json::json!({"account": {"email": "user@example.test"}})),
18964 )
18965 .mount(&server)
18966 .await;
18967 Mock::given(method("POST"))
18968 .and(path("/api/v1/orgs/org-partial-outcome/inference/responses"))
18969 .respond_with(ResponseTemplate::new(200).set_body_raw(
18970 "event: response.output_text.delta\ndata: {\"delta\":\"partial must fail\"}\n\n",
18971 "text/event-stream",
18972 ))
18973 .expect(2)
18974 .mount(&server)
18975 .await;
18976
18977 let tmp = TempDir::new().unwrap();
18978 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
18979 let model_id = "parslee/openrouter/frontier-general";
18980 let schema = crate::openrouter::curated_schemas()
18981 .into_iter()
18982 .find(|schema| schema.id == model_id)
18983 .unwrap();
18984 engine.unified_registry.register_project_model(schema);
18985
18986 let buffered_error = engine
18987 .generate_tracked(GenerateRequest {
18988 prompt: "buffered".into(),
18989 model: Some(model_id.into()),
18990 params: GenerateParams {
18991 strict_model: true,
18992 ..Default::default()
18993 },
18994 ..Default::default()
18995 })
18996 .await
18997 .expect_err("buffered partial EOF must fail");
18998 assert!(
18999 buffered_error.to_string().contains("response.completed"),
19000 "unexpected buffered error: {buffered_error}"
19001 );
19002
19003 let mut stream = engine
19004 .generate_tracked_stream(GenerateRequest {
19005 prompt: "streamed".into(),
19006 model: Some(model_id.into()),
19007 params: GenerateParams {
19008 strict_model: true,
19009 ..Default::default()
19010 },
19011 ..Default::default()
19012 })
19013 .await
19014 .expect("HTTP streaming request starts");
19015 let mut events = Vec::new();
19016 while let Some(event) = stream.events.recv().await {
19017 events.push(event);
19018 }
19019 assert!(
19020 matches!(events.last(), Some(StreamEvent::Error(message)) if message.contains("response.completed"))
19021 );
19022 assert!(!events
19023 .iter()
19024 .any(|event| matches!(event, StreamEvent::Done { .. })));
19025
19026 for _ in 0..50 {
19027 if engine
19028 .outcome_tracker()
19029 .read()
19030 .await
19031 .profile(model_id)
19032 .is_some_and(|profile| profile.fail_count == 2)
19033 {
19034 break;
19035 }
19036 tokio::task::yield_now().await;
19037 }
19038 let profile = engine
19039 .outcome_tracker()
19040 .read()
19041 .await
19042 .profile(model_id)
19043 .cloned()
19044 .unwrap();
19045 assert_eq!(profile.fail_count, 2);
19046 assert_eq!(profile.success_count, 0);
19047
19048 unsafe {
19049 std::env::remove_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV);
19050 std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
19051 }
19052 }
19053
19054 #[tokio::test]
19055 async fn tokenize_rejects_known_remote_model_with_unsupported_mode() {
19056 let tmp = TempDir::new().unwrap();
19061 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19062 let remote_id = engine
19063 .list_schemas()
19064 .into_iter()
19065 .find(|s| !s.is_local())
19066 .map(|s| s.id)
19067 .expect("built-in catalog should include at least one remote model schema");
19068
19069 let err = engine
19070 .tokenize(&remote_id, "hello")
19071 .await
19072 .expect_err("remote tokenize must error");
19073 match err {
19074 InferenceError::UnsupportedMode { mode, backend, .. } => {
19075 assert_eq!(mode, "tokenize/detokenize");
19076 assert_eq!(backend, "remote");
19077 }
19078 other => panic!("expected UnsupportedMode, got {other:?}"),
19079 }
19080
19081 let err = engine
19082 .detokenize(&remote_id, &[1, 2, 3])
19083 .await
19084 .expect_err("remote detokenize must error");
19085 assert!(
19086 matches!(err, InferenceError::UnsupportedMode { .. }),
19087 "expected UnsupportedMode, got {err:?}"
19088 );
19089 }
19090
19091 #[test]
19092 fn unsupported_mode_does_not_trip_circuit_breaker() {
19093 let unsupported = InferenceError::UnsupportedMode {
19099 mode: "structured-output-json-schema",
19100 backend: "anthropic",
19101 reason: "not wired under the pinned API version",
19102 };
19103 assert!(!error_counts_against_circuit_breaker(&unsupported));
19104 assert!(error_counts_against_circuit_breaker(
19105 &InferenceError::InferenceFailed("API returned 500".into())
19106 ));
19107 assert!(error_counts_against_circuit_breaker(
19108 &InferenceError::InferenceFailed("API returned 429".into())
19109 ));
19110 }
19111
19112 #[test]
19120 fn a_content_refusal_does_not_trip_the_circuit_breaker() {
19121 let refused = InferenceError::ContentRefused {
19122 provider: "parslee".into(),
19123 kind: Some("invalid_request_error".into()),
19124 code: Some("content_policy_violation".into()),
19125 message: "content refused".into(),
19126 };
19127 assert!(!error_counts_against_circuit_breaker(&refused));
19128
19129 let rendered = refused.to_string();
19132 assert!(rendered.contains("content grounds"), "{rendered}");
19133 assert!(rendered.contains("content_policy_violation"), "{rendered}");
19134
19135 assert!(error_counts_against_circuit_breaker(
19137 &InferenceError::InferenceFailed("managed inference failed".into())
19138 ));
19139 }
19140
19141 #[test]
19151 fn a_content_refusal_ends_the_fallback_chain() {
19152 let refused = InferenceError::ContentRefused {
19153 provider: "parslee".into(),
19154 kind: Some("invalid_request_error".into()),
19155 code: Some("content_policy_violation".into()),
19156 message: "content refused".into(),
19157 };
19158 assert!(error_ends_fallback_chain(&refused));
19159
19160 for still_advances in [
19164 InferenceError::InferenceFailed("managed inference failed".into()),
19165 InferenceError::InferenceFailed("API returned 503".into()),
19166 InferenceError::ModelNotFound("parslee/reasoning".into()),
19167 InferenceError::UnsupportedMode {
19168 mode: "json_schema",
19169 backend: "anthropic",
19170 reason: "structured output is not supported by this protocol",
19171 },
19172 InferenceError::GatewayUnconfigured {
19173 provider: "parslee".into(),
19174 namespace: "parslee/openrouter/".into(),
19175 status: 503,
19176 message: "not configured".into(),
19177 },
19178 ] {
19179 assert!(
19180 !error_ends_fallback_chain(&still_advances),
19181 "must keep advancing the chain: {still_advances}"
19182 );
19183 }
19184 }
19185
19186 #[test]
19195 fn recovery_hints_do_not_rewrite_a_content_refusal() {
19196 let refused = InferenceError::ContentRefused {
19197 provider: "parslee".into(),
19198 kind: Some("invalid_request_error".into()),
19199 code: Some("content_policy_violation".into()),
19200 message: "blocked: 403 forbidden by the content filter".into(),
19202 };
19203 let out = apply_exhaustion_recovery_hint(refused);
19204 assert!(
19205 matches!(out, InferenceError::ContentRefused { .. }),
19206 "{out:?}"
19207 );
19208
19209 let signed_out = apply_exhaustion_recovery_hint(InferenceError::InferenceFailed(
19211 "no credential for proprietary provider 'parslee'".into(),
19212 ));
19213 assert!(
19214 matches!(signed_out, InferenceError::InferenceFailed(ref m) if m.contains("car auth")),
19215 "{signed_out:?}"
19216 );
19217
19218 let transient = apply_exhaustion_recovery_hint(InferenceError::InferenceFailed(
19220 "API returned 500".into(),
19221 ));
19222 assert_eq!(transient.to_string(), "inference failed: API returned 500");
19223 }
19224
19225 #[test]
19233 fn unconfigured_gateway_does_not_trip_circuit_breaker() {
19234 let unconfigured = InferenceError::GatewayUnconfigured {
19235 provider: "parslee".into(),
19236 namespace: "parslee/openrouter/".into(),
19237 status: 503,
19238 message: "OpenRouter inference is not configured on this Parslee environment.".into(),
19239 };
19240 assert!(!error_counts_against_circuit_breaker(&unconfigured));
19241 let rendered = unconfigured.to_string();
19245 assert!(rendered.contains("parslee/openrouter/"), "{rendered}");
19246 assert!(rendered.contains("not configured"), "{rendered}");
19247 }
19248
19249 #[test]
19255 fn namespace_drop_spares_siblings_outside_the_prefix() {
19256 let namespace = "parslee/openrouter/";
19257 let mut queue: std::collections::VecDeque<String> = [
19258 "parslee/openrouter/open-fast",
19259 "parslee/reasoning",
19260 "parslee/openrouter/frontier-general",
19261 "parslee/advisor",
19262 "anthropic/claude-opus-4-8:latest",
19263 ]
19264 .into_iter()
19265 .map(String::from)
19266 .collect();
19267
19268 queue.retain(|id| !id.starts_with(namespace));
19269
19270 assert_eq!(
19271 queue.iter().collect::<Vec<_>>(),
19272 vec![
19273 "parslee/reasoning",
19274 "parslee/advisor",
19275 "anthropic/claude-opus-4-8:latest"
19276 ],
19277 "only the unconfigured namespace may be dropped"
19278 );
19279 }
19280
19281 #[test]
19282 fn engine_loads_benchmark_priors_on_startup() {
19283 let _env = ENV_MUTEX.blocking_lock();
19284 let tmp = TempDir::new().unwrap();
19285 let priors_path = tmp.path().join("benchmark_priors.json");
19286 std::fs::write(
19287 &priors_path,
19288 serde_json::json!({
19289 "model_id": "qwen/qwen3-8b:q4_k_m",
19290 "overall_score": 0.88
19291 })
19292 .to_string(),
19293 )
19294 .unwrap();
19295
19296 unsafe {
19297 std::env::set_var("CAR_BENCHMARK_PRIORS_PATH", &priors_path);
19298 }
19299
19300 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19301 let tracker = engine.outcome_tracker.blocking_read();
19302 let profile = tracker
19303 .profile("qwen/qwen3-8b:q4_k_m")
19304 .expect("benchmark prior should create a profile");
19305 assert!((profile.ema_quality - 0.88).abs() < 0.01);
19306
19307 unsafe {
19308 std::env::remove_var("CAR_BENCHMARK_PRIORS_PATH");
19309 }
19310 }
19311
19312 #[test]
19313 fn benchmark_priors_do_not_override_observed_profiles() {
19314 let _env = ENV_MUTEX.blocking_lock();
19315 let tmp = TempDir::new().unwrap();
19316 let models_dir = tmp.path().join("models");
19317 std::fs::create_dir_all(&models_dir).unwrap();
19318
19319 let observed = vec![ModelProfile {
19320 model_id: "qwen/qwen3-8b:q4_k_m".into(),
19321 total_calls: 12,
19322 success_count: 3,
19323 fail_count: 9,
19324 total_latency_ms: 1200,
19325 total_input_tokens: 0,
19326 total_output_tokens: 0,
19327 total_cache_read_input_tokens: 0,
19328 total_cache_creation_input_tokens: 0,
19329 task_stats: std::collections::HashMap::new(),
19330 ema_quality: 0.21,
19331 prior_sample_size: 0,
19332 quality_observations: 0,
19333 quality_per_1k_tokens: 0.0,
19334 updated_at: 1,
19335 }];
19336 std::fs::write(
19337 models_dir.join("outcome_profiles.json"),
19338 serde_json::to_string(&observed).unwrap(),
19339 )
19340 .unwrap();
19341
19342 let priors_path = tmp.path().join("benchmark_priors.json");
19343 std::fs::write(
19344 &priors_path,
19345 serde_json::json!({
19346 "model_id": "qwen/qwen3-8b:q4_k_m",
19347 "overall_score": 0.95
19348 })
19349 .to_string(),
19350 )
19351 .unwrap();
19352
19353 unsafe {
19354 std::env::set_var("CAR_BENCHMARK_PRIORS_PATH", &priors_path);
19355 }
19356
19357 let engine = InferenceEngine::new(test_config(models_dir));
19358 let tracker = engine.outcome_tracker.blocking_read();
19359 let profile = tracker
19360 .profile("qwen/qwen3-8b:q4_k_m")
19361 .expect("observed profile should remain present");
19362 assert!((profile.ema_quality - 0.21).abs() < 0.01);
19363 assert_eq!(profile.total_calls, 12);
19364
19365 unsafe {
19366 std::env::remove_var("CAR_BENCHMARK_PRIORS_PATH");
19367 }
19368 }
19369
19370 #[test]
19371 fn speech_runtime_package_spec_defaults_and_overrides() {
19372 let _env = ENV_MUTEX.blocking_lock();
19373 unsafe {
19374 std::env::remove_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC");
19375 }
19376 assert_eq!(speech_runtime_mlx_audio_spec(), "mlx-audio==0.4.2");
19377
19378 unsafe {
19379 std::env::set_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC", "mlx-audio==0.4.1");
19380 }
19381 assert_eq!(speech_runtime_mlx_audio_spec(), "mlx-audio==0.4.1");
19382
19383 unsafe {
19384 std::env::remove_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC");
19385 }
19386 }
19387
19388 #[test]
19389 fn speech_runtime_spacy_model_spec_defaults_and_overrides() {
19390 let _env = ENV_MUTEX.blocking_lock();
19391 unsafe {
19392 std::env::remove_var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC");
19393 }
19394 assert!(
19395 speech_runtime_spacy_model_spec().starts_with("en-core-web-sm @ https://github.com/")
19396 );
19397
19398 unsafe {
19399 std::env::set_var(
19400 "CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC",
19401 "en-core-web-sm==3.8.0",
19402 );
19403 }
19404 assert_eq!(speech_runtime_spacy_model_spec(), "en-core-web-sm==3.8.0");
19405
19406 unsafe {
19407 std::env::remove_var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC");
19408 }
19409 }
19410
19411 #[test]
19412 fn kokoro_runtime_fallback_defaults_on() {
19413 unsafe {
19414 std::env::remove_var("CAR_SPEECH_KOKORO_FALLBACK");
19415 }
19416 assert!(kokoro_runtime_fallback_enabled());
19417
19418 unsafe {
19419 std::env::set_var("CAR_SPEECH_KOKORO_FALLBACK", "false");
19420 }
19421 assert!(!kokoro_runtime_fallback_enabled());
19422
19423 unsafe {
19424 std::env::remove_var("CAR_SPEECH_KOKORO_FALLBACK");
19425 }
19426 }
19427
19428 #[test]
19429 fn preferred_local_tts_wins_over_builtin_rank() {
19430 let tmp = TempDir::new().unwrap();
19431 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19432 engine.set_speech_policy(SpeechPolicy {
19433 prefer_local: true,
19434 allow_remote_fallback: false,
19435 preferred_local_stt: None,
19436 preferred_local_tts: Some("Kokoro-82M-6bit".into()),
19437 preferred_remote_stt: None,
19438 preferred_remote_tts: None,
19439 });
19440
19441 let schema = engine
19442 .preferred_speech_schema(ModelCapability::TextToSpeech, true, false)
19443 .expect("preferred local TTS should resolve");
19444 #[cfg(not(target_os = "windows"))]
19451 assert_eq!(schema.name, "Kokoro-82M-6bit");
19452 #[cfg(target_os = "windows")]
19453 assert_eq!(schema.name, "Windows-Speech");
19454 }
19455
19456 #[test]
19457 fn preferred_discovered_vllm_mlx_model_wins_generate_routing() {
19458 let tmp = TempDir::new().unwrap();
19459 let mut config = test_config(tmp.path().join("models"));
19460 config.preferred_generation_model =
19461 Some("vllm-mlx/mlx-community_gemma-3n-E2B-it-lm-4bit".into());
19462 let mut engine = InferenceEngine::new(config);
19463 let schema = crate::vllm_mlx::to_model_schema(
19464 &crate::vllm_mlx::DiscoveredModel {
19465 id: "mlx-community/gemma-3n-E2B-it-lm-4bit".into(),
19466 owned_by: Some("mlx-community".into()),
19467 },
19468 "http://127.0.0.1:8001",
19469 );
19470 engine.register_model(schema);
19471
19472 let rt = tokio::runtime::Runtime::new().unwrap();
19473 let decision = rt.block_on(engine.route_adaptive("say hello in one sentence"));
19474 assert_eq!(
19475 decision.model_id,
19476 "vllm-mlx/mlx-community_gemma-3n-E2B-it-lm-4bit"
19477 );
19478 assert_eq!(decision.strategy, RoutingStrategy::Explicit);
19479 assert_eq!(decision.reason, "preferred generation model override");
19480 }
19481
19482 #[test]
19491 fn tools_request_on_non_tool_model_terminates_not_spins() {
19492 let tmp = TempDir::new().unwrap();
19493 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19494 let schema = crate::vllm_mlx::to_model_schema(
19496 &crate::vllm_mlx::DiscoveredModel {
19497 id: "test-org/embed-only-model".into(),
19498 owned_by: None,
19499 },
19500 "http://127.0.0.1:1", );
19502 let model_id = schema.id.clone();
19503 engine.register_model(schema);
19504
19505 let mut req = GenerateRequest {
19506 prompt: "call a tool".to_string(),
19507 model: Some(model_id),
19508 tools: Some(vec![serde_json::json!({
19509 "name": "noop", "description": "n", "parameters": {"type": "object"}
19510 })]),
19511 ..Default::default()
19512 };
19513 req.params.strict_model = true;
19514
19515 let rt = tokio::runtime::Runtime::new().unwrap();
19516 let out = rt.block_on(async {
19517 tokio::time::timeout(
19518 std::time::Duration::from_secs(10),
19519 engine.generate_tracked(req),
19520 )
19521 .await
19522 });
19523 let completed = out.expect("fallback loop must terminate, not spin");
19526 assert!(completed.is_err());
19527 }
19528
19529 fn fake_ready_speech_runtime(root: &Path) {
19540 managed_venv::seed_ready_venv(root);
19541 let runtime = SpeechRuntime::new(root.to_path_buf());
19542 for program in [&runtime.stt_program, &runtime.tts_program] {
19543 std::fs::create_dir_all(program.parent().expect("program has a parent")).unwrap();
19544 std::fs::write(program, b"").unwrap();
19545 }
19546 }
19547
19548 #[tokio::test]
19575 async fn prepare_speech_runtime_returns_the_root_doctor_reports() {
19576 let _env = ENV_MUTEX.lock().await;
19577 let tmp = TempDir::new().unwrap();
19578 let runtime_root = tmp.path().join("speech-runtime");
19579 fake_ready_speech_runtime(&runtime_root);
19580 unsafe {
19581 std::env::set_var("CAR_SPEECH_RUNTIME_DIR", &runtime_root);
19582 }
19583
19584 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19585 let root = engine
19586 .prepare_speech_runtime()
19587 .await
19588 .expect("prepare should succeed against a ready runtime");
19589 let health = engine.speech_health();
19590
19591 assert_eq!(
19592 root, health.runtime.root,
19593 "prepare returned a different root than doctor reports"
19594 );
19595 assert!(
19596 root.exists(),
19597 "prepare returned {} but it does not exist",
19598 root.display()
19599 );
19600 assert!(
19601 health.runtime.installed,
19602 "doctor should report a ready runtime as installed"
19603 );
19604 assert_eq!(
19606 engine
19607 .prepare_speech_runtime()
19608 .await
19609 .expect("second prepare should succeed"),
19610 root
19611 );
19612
19613 unsafe {
19614 std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
19615 }
19616 }
19617
19618 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
19628 #[tokio::test]
19629 async fn prepare_speech_runtime_degrades_when_bootstrap_fails() {
19630 let _env = ENV_MUTEX.lock().await;
19631 let tmp = TempDir::new().unwrap();
19632 let runtime_root = tmp.path().join("speech-runtime");
19633 assert!(!runtime_root.exists(), "precondition: root absent");
19634 unsafe {
19635 std::env::set_var("CAR_SPEECH_RUNTIME_DIR", &runtime_root);
19636 std::env::set_var("CAR_SPEECH_PYTHON", tmp.path().join("no-such-python"));
19637 }
19638
19639 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19640 let root = engine
19641 .prepare_speech_runtime()
19642 .await
19643 .expect("a failed bootstrap must degrade, not fail, on Apple Silicon");
19644 let health = engine.speech_health();
19645
19646 assert_eq!(root, health.runtime.root);
19647 assert!(
19648 root.exists(),
19649 "prepare returned {} but it does not exist",
19650 root.display()
19651 );
19652 assert!(
19653 !health.runtime.installed,
19654 "doctor must not claim a runtime the bootstrap never built"
19655 );
19656
19657 unsafe {
19658 std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
19659 std::env::remove_var("CAR_SPEECH_PYTHON");
19660 }
19661 }
19662
19663 #[cfg(target_os = "linux")]
19676 #[tokio::test]
19677 async fn a_runtime_that_cannot_be_built_no_longer_blocks_the_whisper_install() {
19678 let _env = ENV_MUTEX.lock().await;
19679 let tmp = TempDir::new().unwrap();
19680
19681 let cached = tmp
19685 .path()
19686 .join(".tokhn")
19687 .join("whisper")
19688 .join("ggml-large-v3-turbo-q5_0.bin");
19689 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
19690 std::fs::write(&cached, b"stand-in for the ggml weights").unwrap();
19691
19692 let previous_home = std::env::var_os("HOME");
19693 unsafe {
19694 std::env::set_var("HOME", tmp.path());
19695 std::env::set_var("CAR_SPEECH_RUNTIME_DIR", tmp.path().join("speech-runtime"));
19696 std::env::set_var("CAR_SPEECH_PYTHON", tmp.path().join("no-such-python"));
19700 }
19701
19702 let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19703 let installed = engine.install_curated_speech().await;
19704
19705 let restore = || unsafe {
19706 match &previous_home {
19707 Some(value) => std::env::set_var("HOME", value),
19708 None => std::env::remove_var("HOME"),
19709 }
19710 std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
19711 std::env::remove_var("CAR_SPEECH_PYTHON");
19712 };
19713 let installed = match installed {
19714 Ok(installed) => installed,
19715 Err(error) => {
19716 restore();
19717 panic!("a runtime that cannot be built must not abort the install: {error}");
19718 }
19719 };
19720 let runtime_installed = engine.speech_health().runtime.installed;
19721 restore();
19722
19723 assert!(
19724 !runtime_installed,
19725 "precondition: the bootstrap must actually have failed, or this \
19726 test would pass without exercising anything"
19727 );
19728 let whisper = installed
19729 .iter()
19730 .find(|report| report.hf_repo == "ggerganov/whisper.cpp")
19731 .unwrap_or_else(|| {
19732 panic!(
19733 "the cross-platform whisper model must still be installed, got: {installed:?}"
19734 )
19735 });
19736 assert_eq!(whisper.snapshot_path, cached);
19737 assert!(
19738 installed
19739 .iter()
19740 .all(|report| report.hf_repo == "ggerganov/whisper.cpp"),
19741 "MLX weights are Apple-only and must not be pulled here — over a \
19742 gigabyte of them, for models this host can never load; got: {installed:?}"
19743 );
19744 }
19745
19746 #[test]
19754 fn alias_resolves_to_canonical_id_for_outcome_keying() {
19755 let tmp = TempDir::new().unwrap();
19756 let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
19757
19758 let cases = [
19760 ("claude-sonnet-4-6", "anthropic/claude-sonnet-4-6:latest"),
19761 ("gpt-5.4", "openai/gpt-5.4:latest"),
19762 ("gemini-2.5-flash", "google/gemini-2.5-flash:latest"),
19763 ];
19764 for (alias, canonical) in cases {
19765 let resolved = engine
19768 .unified_registry
19769 .get(alias)
19770 .or_else(|| engine.unified_registry.find_by_name(alias))
19771 .map(|s| s.id.clone())
19772 .unwrap_or_else(|| alias.to_string());
19773 assert_eq!(
19774 resolved, canonical,
19775 "alias `{alias}` must resolve to canonical `{canonical}` for outcome keying, got `{resolved}`"
19776 );
19777 assert_ne!(
19778 resolved, alias,
19779 "alias `{alias}` must NOT be recorded raw — that is the fragmentation bug"
19780 );
19781 }
19782 }
19783
19784 #[test]
19789 fn inference_result_serializes_with_full_shape() {
19790 use crate::tasks::generate::ToolCall;
19791 use std::collections::HashMap;
19792
19793 let mut args = HashMap::new();
19794 args.insert("path".to_string(), serde_json::json!("README.md"));
19795
19796 let result = InferenceResult {
19797 text: String::new(),
19798 bounding_boxes: Vec::new(),
19799 tool_calls: vec![ToolCall {
19800 id: None,
19801 name: "read_file".into(),
19802 arguments: args,
19803 }],
19804 trace_id: "trace-abc".into(),
19805 model_used: "test-model".into(),
19806 model_identity: InferenceModelIdentity {
19807 requested_model_id: Some("test/model:1".into()),
19808 resolved_model_id: "test/model:1".into(),
19809 row_digest: "a".repeat(64),
19810 catalog_revision: "b".repeat(64),
19811 },
19812 latency_ms: 1234,
19813 time_to_first_token_ms: Some(180),
19814 usage: Some(TokenUsage {
19815 prompt_tokens: 100,
19816 completion_tokens: 50,
19817 total_tokens: 150,
19818 context_window: 8192,
19819 ..Default::default()
19820 }),
19821 provider_output_items: Vec::new(),
19822 thinking: Vec::new(),
19823 stop_reason: Some("tool_use".into()),
19824 auth_fallback_from: None,
19825 local_last_resort: false,
19826 fallback_from: Vec::new(),
19827 };
19828
19829 let json = serde_json::to_value(&result).expect("serialize");
19830
19831 assert_eq!(json["stop_reason"].as_str(), Some("tool_use"));
19833
19834 assert_eq!(json["text"].as_str(), Some(""));
19836 assert_eq!(json["trace_id"].as_str(), Some("trace-abc"));
19837 assert_eq!(json["model_used"].as_str(), Some("test-model"));
19838 assert_eq!(json["requested_model_id"].as_str(), Some("test/model:1"));
19839 assert_eq!(json["resolved_model_id"].as_str(), Some("test/model:1"));
19840 assert_eq!(json["row_digest"].as_str().unwrap(), "a".repeat(64));
19841 assert_eq!(json["catalog_revision"].as_str().unwrap(), "b".repeat(64));
19842 assert_eq!(json["latency_ms"].as_u64(), Some(1234));
19843
19844 let tool_calls = json["tool_calls"].as_array().expect("tool_calls array");
19846 assert_eq!(tool_calls.len(), 1);
19847 assert_eq!(tool_calls[0]["name"].as_str(), Some("read_file"));
19848 assert_eq!(
19849 tool_calls[0]["arguments"]["path"].as_str(),
19850 Some("README.md")
19851 );
19852
19853 let usage = &json["usage"];
19855 assert_eq!(usage["prompt_tokens"].as_u64(), Some(100));
19856 assert_eq!(usage["completion_tokens"].as_u64(), Some(50));
19857 assert_eq!(usage["total_tokens"].as_u64(), Some(150));
19858 assert_eq!(usage["context_window"].as_u64(), Some(8192));
19859
19860 assert_eq!(json["time_to_first_token_ms"].as_u64(), Some(180));
19862 }
19863
19864 #[test]
19870 fn inference_result_top_level_keys_are_locked() {
19871 use std::collections::BTreeSet;
19872
19873 let result = InferenceResult {
19874 text: "anything".into(),
19875 bounding_boxes: Vec::new(),
19876 tool_calls: vec![],
19877 trace_id: "t".into(),
19878 model_used: "m".into(),
19879 model_identity: InferenceModelIdentity::default(),
19880 latency_ms: 0,
19881 time_to_first_token_ms: None,
19882 usage: None,
19883 provider_output_items: Vec::new(),
19884 thinking: Vec::new(),
19885 stop_reason: None,
19886 auth_fallback_from: None,
19887 local_last_resort: false,
19888 fallback_from: Vec::new(),
19889 };
19890
19891 let json = serde_json::to_value(&result).expect("serialize");
19892 let keys: BTreeSet<&str> = json
19893 .as_object()
19894 .expect("top-level object")
19895 .keys()
19896 .map(String::as_str)
19897 .collect();
19898
19899 let expected: BTreeSet<&str> = [
19900 "text",
19901 "tool_calls",
19902 "trace_id",
19903 "model_used",
19904 "requested_model_id",
19905 "resolved_model_id",
19906 "row_digest",
19907 "catalog_revision",
19908 "latency_ms",
19909 "time_to_first_token_ms",
19910 "usage",
19911 "stop_reason",
19912 ]
19913 .into_iter()
19914 .collect();
19915
19916 assert_eq!(
19917 keys, expected,
19918 "infer response top-level keys drifted -- update both the test \
19919 and the WebSocket protocol documentation if this is intentional"
19920 );
19921
19922 for key in &keys {
19924 assert!(
19925 !key.chars().any(|c| c.is_uppercase()) && !key.contains('-'),
19926 "key '{}' is not snake_case",
19927 key
19928 );
19929 }
19930 }
19931
19932 #[test]
19936 fn inference_result_serializes_plain_text_response() {
19937 let result = InferenceResult {
19938 text: "hello world".into(),
19939 bounding_boxes: Vec::new(),
19940 tool_calls: vec![],
19941 trace_id: "trace-xyz".into(),
19942 model_used: "test-model".into(),
19943 model_identity: InferenceModelIdentity::default(),
19944 latency_ms: 42,
19945 time_to_first_token_ms: None,
19946 usage: None,
19947 provider_output_items: Vec::new(),
19948 thinking: Vec::new(),
19949 stop_reason: None,
19950 auth_fallback_from: None,
19951 local_last_resort: false,
19952 fallback_from: Vec::new(),
19953 };
19954
19955 let json = serde_json::to_value(&result).expect("serialize");
19956 assert_eq!(json["text"], "hello world");
19957 assert!(json["stop_reason"].is_null());
19959 assert!(json["tool_calls"].is_array());
19960 assert_eq!(json["tool_calls"].as_array().unwrap().len(), 0);
19961 assert_eq!(json["model_used"], "test-model");
19962 assert!(json["usage"].is_null());
19963 assert!(json["time_to_first_token_ms"].is_null());
19966 }
19967
19968 #[test]
19969 fn append_assistant_history_preserves_responses_items_in_provider_order() {
19970 let reasoning = serde_json::json!({
19971 "type": "reasoning",
19972 "id": "rs_history",
19973 "status": "completed",
19974 "summary": [{"type": "summary_text", "text": "safe"}],
19975 "encrypted_content": "opaque",
19976 });
19977 let result: InferenceResult = serde_json::from_value(serde_json::json!({
19978 "text": "calling",
19979 "tool_calls": [{
19980 "id": "call_1",
19981 "name": "read_file",
19982 "arguments": {"path": "README.md"}
19983 }],
19984 "trace_id": "trace",
19985 "model_used": "gateway-alias",
19986 "resolved_model_id": "openrouter/anthropic/claude-sonnet-4.5",
19987 "local_last_resort": true,
19988 "latency_ms": 1,
19989 "provider_output_items": [reasoning.clone()],
19990 }))
19991 .unwrap();
19992 let mut history = vec![crate::tasks::generate::Message::User {
19993 content: "inspect".into(),
19994 }];
19995
19996 result.append_assistant_history(&mut history, result.tool_calls.clone());
19997
19998 assert!(matches!(
19999 &history[1],
20000 crate::tasks::generate::Message::ProviderOutputItems { protocol, items }
20001 if protocol == crate::protocol::OPENAI_RESPONSES_PROTOCOL
20002 && items == &vec![reasoning]
20003 ));
20004 assert!(matches!(
20005 &history[2],
20006 crate::tasks::generate::Message::Assistant {
20007 content,
20008 tool_calls,
20009 model_id,
20010 local_last_resort,
20011 ..
20012 } if content == "calling"
20013 && tool_calls[0].id.as_deref() == Some("call_1")
20014 && model_id.as_deref() == Some("openrouter/anthropic/claude-sonnet-4.5")
20015 && *local_last_resort
20016 ));
20017 }
20018
20019 #[test]
20020 fn append_assistant_history_leaves_personal_chat_history_unchanged() {
20021 let result: InferenceResult = serde_json::from_value(serde_json::json!({
20022 "text": "plain",
20023 "tool_calls": [],
20024 "trace_id": "trace",
20025 "model_used": "openrouter/openai/gpt-4.1-mini",
20026 "latency_ms": 1,
20027 }))
20028 .unwrap();
20029 let mut history = Vec::new();
20030
20031 result.append_assistant_history(&mut history, Vec::new());
20032
20033 assert_eq!(history.len(), 1);
20034 assert!(matches!(
20035 &history[0],
20036 crate::tasks::generate::Message::Assistant { content, .. } if content == "plain"
20037 ));
20038 }
20039
20040 #[test]
20052 fn generate_request_deserializes_intent_field_from_json_rpc_params() {
20053 use crate::intent::TaskHint;
20054 use crate::schema::ModelCapability;
20055
20056 let params = serde_json::json!({
20059 "prompt": "summarize this email",
20060 "intent": {
20061 "task": "chat",
20062 "prefer_local": true,
20063 "require": ["tool_use"],
20064 },
20065 });
20066
20067 let req: GenerateRequest =
20068 serde_json::from_value(params).expect("GenerateRequest deserialize");
20069
20070 let intent = req.intent.as_ref().expect("intent field deserialized");
20071 assert_eq!(intent.task, Some(TaskHint::Chat));
20072 assert!(intent.prefer_local);
20073 assert_eq!(intent.require, vec![ModelCapability::ToolUse]);
20074
20075 let back: serde_json::Value =
20079 serde_json::to_value(&req).expect("re-serialize GenerateRequest");
20080 assert_eq!(back["intent"]["task"], "chat");
20081 assert_eq!(back["intent"]["prefer_local"], true);
20082 assert_eq!(back["intent"]["require"][0], "tool_use");
20083
20084 let default_req: GenerateRequest = serde_json::from_value(serde_json::json!({
20089 "prompt": "x",
20090 "intent": {},
20091 }))
20092 .unwrap();
20093 let default_intent = default_req.intent.expect("present but empty");
20094 assert_eq!(default_intent.task, None);
20095 assert!(!default_intent.prefer_local);
20096 assert!(default_intent.require.is_empty());
20097
20098 let no_intent: GenerateRequest =
20101 serde_json::from_value(serde_json::json!({"prompt": "x"})).unwrap();
20102 assert!(no_intent.intent.is_none());
20103 }
20104
20105 #[test]
20106 fn rerank_prompt_matches_upstream_template_shape() {
20107 let p = rerank_prompt(
20108 "retrieve relevant passages",
20109 "who runs the treasury?",
20110 "doc x",
20111 );
20112 assert!(p.contains("<|im_start|>system"));
20113 assert!(p.contains("Note that the answer can only be \"yes\" or \"no\"."));
20114 assert!(p.contains("<|im_start|>user\n<Instruct>: retrieve relevant passages"));
20115 assert!(p.contains("<Query>: who runs the treasury?"));
20116 assert!(p.contains("<Document>: doc x<|im_end|>"));
20117 assert!(p.contains("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
20118 }
20119
20120 #[test]
20121 fn rerank_score_yes_and_no_exactly() {
20122 assert_eq!(score_from_rerank_output("yes", "m"), 1.0);
20123 assert_eq!(score_from_rerank_output("no", "m"), 0.0);
20124 }
20125
20126 #[test]
20127 fn rerank_score_handles_case_leading_space_and_chat_sentinels() {
20128 assert_eq!(score_from_rerank_output(" Yes", "m"), 1.0);
20131 assert_eq!(score_from_rerank_output("\nno.", "m"), 0.0);
20132 assert_eq!(score_from_rerank_output("<|im_end|>yes", "m"), 1.0);
20133 }
20134
20135 #[test]
20136 fn rerank_score_scans_up_to_three_tokens() {
20137 assert_eq!(score_from_rerank_output("_bos_ yes", "m"), 1.0);
20140 }
20141
20142 #[test]
20143 fn rerank_score_unexpected_is_neutral() {
20144 assert_eq!(score_from_rerank_output("maybe", "m"), 0.5);
20147 assert_eq!(score_from_rerank_output("", "m"), 0.5);
20148 }
20149
20150 #[tokio::test]
20151 async fn pull_reuses_a_valid_directory_receipt_and_reports_removability() {
20152 let root = tempfile::tempdir().unwrap();
20153 let models_dir = root.path().join("models");
20154 std::fs::create_dir_all(&models_dir).unwrap();
20155 let engine = InferenceEngine::new(InferenceConfig {
20156 state_root: root.path().join("state"),
20157 models_dir: models_dir.clone(),
20158 ..InferenceConfig::default()
20159 });
20160 let schema = engine
20161 .unified_registry
20162 .all()
20163 .find(|schema| matches!(schema.source, ModelSource::Local { .. }))
20164 .unwrap()
20165 .clone();
20166 let managed = models_dir.join(&schema.name);
20167 std::fs::create_dir_all(&managed).unwrap();
20168 std::fs::write(managed.join("model.gguf"), b"owned").unwrap();
20169 std::fs::write(managed.join("tokenizer.json"), b"{}").unwrap();
20170 engine
20171 .model_management
20172 .record_managed_artifact(
20173 &schema.id,
20174 model_source_identity(&schema),
20175 None,
20176 1,
20177 false,
20178 managed.clone(),
20179 )
20180 .unwrap();
20181 let row = engine
20182 .list_models_unified()
20183 .into_iter()
20184 .find(|row| row.id == schema.id)
20185 .unwrap();
20186 if model_management::directory_removal_supported() {
20187 assert!(engine.model_management.can_remove(&schema.id).unwrap());
20188 assert!(row.can_remove);
20189 assert_eq!(row.management_evidence.as_deref(), Some("install_receipt"));
20190 } else {
20191 assert!(!engine.model_management.can_remove(&schema.id).unwrap());
20192 assert!(!row.can_remove);
20193 assert_eq!(
20194 row.management_evidence.as_deref(),
20195 Some("install_receipt_directory_cleanup_unsupported")
20196 );
20197 }
20198
20199 let reused = engine.pull_model(&schema.id).await.unwrap();
20200 assert_eq!(reused, managed);
20201 }
20202
20203 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
20204 #[test]
20205 fn kokoro_cache_is_explicitly_state_root_scoped_and_does_not_pin_runtime() {
20206 let first_root = tempfile::tempdir().unwrap();
20207 let second_root = tempfile::tempdir().unwrap();
20208 let first = scoped_kokoro_backend_cache(first_root.path());
20209 let second = scoped_kokoro_backend_cache(second_root.path());
20210 assert!(!Arc::ptr_eq(first.cache(), second.cache()));
20211 assert!(!Arc::ptr_eq(first.admission(), second.admission()));
20212
20213 let runtime = Arc::downgrade(&first._runtime);
20214 drop(first);
20215 assert!(
20216 runtime.upgrade().is_none(),
20217 "an unused Kokoro accessor must not process-pin the scoped runtime"
20218 );
20219 }
20220}
20221
20222#[cfg(test)]
20223mod response_format_support_tests {
20224 use super::*;
20225
20226 #[test]
20230 fn rejection_reason_tracks_the_protocol_handler() {
20231 let engine = InferenceEngine::new(Default::default());
20232 let rf = crate::tasks::generate::ResponseFormat::JsonObject;
20233 let models = engine.list_models_unified();
20234 let anthropic = models
20235 .iter()
20236 .find(|m| m.provider.eq_ignore_ascii_case("anthropic"));
20237 if let Some(m) = anthropic {
20238 let reason = engine
20239 .response_format_rejection_reason(&m.id, &rf)
20240 .expect("the Anthropic protocol rejects response_format");
20241 assert!(reason.contains("protocol rejects"), "{reason}");
20242 }
20243 let openrouter = models
20244 .iter()
20245 .find(|m| m.provider.eq_ignore_ascii_case("openrouter"));
20246 if let Some(m) = openrouter {
20247 assert_eq!(
20248 engine.response_format_rejection_reason(&m.id, &rf),
20249 None,
20250 "OpenRouter forwards the format upstream"
20251 );
20252 }
20253 assert!(
20254 anthropic.is_some() || openrouter.is_some(),
20255 "the builtin catalog should list at least one of the two providers this pins"
20256 );
20257 assert_eq!(
20258 engine.response_format_rejection_reason("no/such-model", &rf),
20259 None,
20260 "an unknown model is not a rejection"
20261 );
20262 }
20263}