Skip to main content

aurum_core/
engine.rs

1//! Owned engine boundary for library hosts (JOE-1782 / JOE-1654 / JOE-1784 / JOE-1787 / JOE-1938).
2//!
3//! # Ownership model
4//!
5//! [`AurumEngine`] owns:
6//! * a [`ValidatedConfig`]
7//! * an engine-local [`ResourceGovernor`]
8//! * an engine-local [`Metrics`] sink
9//! * an engine-local STT context pool ([`SttContextPool`])
10//! * an engine-local TTS session pool (when the `tts` feature is enabled)
11//! * an immutable [`ProviderRegistry`] (builtin factories by default)
12//! * lifecycle bookkeeping for explicit shutdown
13//!
14//! # Provider resolution (JOE-1938)
15//!
16//! High-level [`Self::transcribe`] / [`Self::synthesize`] and
17//! [`Self::stt_provider`] / [`Self::tts_provider`] route through the registry.
18//! Concrete vendor construction stays inside factories; the engine assembles a
19//! **single-provider** [`ProviderBuildContext`] (no multi-vendor secret bag).
20//!
21//! # Isolation (JOE-1784)
22//!
23//! Engines do **not** share whisper/TTS residency with each other or with the
24//! process-global pools used by default `LocalWhisperProvider::new` /
25//! `LocalTtsProvider::new`. Shutdown clears **idle** entries in this engine's
26//! pools only.
27//!
28//! Process-global pools remain for CLI and callers that construct providers
29//! without an engine. Call [`crate::providers::local::clear_context_cache`] at
30//! process exit when using those paths with Metal.
31
32use crate::audio::AudioInput;
33use crate::config::{Config, ValidatedConfig};
34use crate::doctor::{run_doctor, DoctorReport};
35use crate::error::{ErrorCategory, Result, UserError};
36use crate::observability::{
37    Metrics, MetricsSnapshot, OpEvent, OpKind, OpStage, TerminalCategory, TerminalGuard,
38};
39use crate::provider_platform::{
40    ProviderBuildContext, ProviderId, ProviderRegistry, ProviderResolveOptions,
41};
42use crate::providers::local::{LocalWhisperProvider, SttContextPool};
43use crate::providers::{
44    OpenRouterSttMode, TranscriptionOptions, TranscriptionProvider, TranscriptionResult,
45};
46use crate::runtime::{GovernorConfig, OpContext, ResourceGovernor};
47use crate::sdk::{OperationOptions, TranscriptionRequest};
48use crate::support::{build_support_bundle, SupportBundle};
49use std::sync::atomic::{AtomicBool, Ordering};
50use std::sync::Arc;
51
52#[cfg(feature = "tts")]
53use crate::tts::local::{LocalTtsProvider, TtsSessionPool};
54#[cfg(feature = "tts")]
55use crate::tts::provider::{SynthesisOptions, SynthesisProvider, SynthesisResult};
56
57/// Library-facing engine: validated config + owned governor/metrics/model pools + registry.
58pub struct AurumEngine {
59    config: ValidatedConfig,
60    governor: Arc<ResourceGovernor>,
61    metrics: Arc<Metrics>,
62    stt_pool: Arc<SttContextPool>,
63    #[cfg(feature = "tts")]
64    tts_pool: Arc<TtsSessionPool>,
65    registry: Arc<ProviderRegistry>,
66    closed: AtomicBool,
67}
68
69impl std::fmt::Debug for AurumEngine {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        let mut d = f.debug_struct("AurumEngine");
72        d.field("config", &self.config)
73            .field("closed", &self.closed.load(Ordering::SeqCst))
74            .field("metrics", &self.metrics.snapshot())
75            .field("stt_resident", &self.stt_pool.resident_len())
76            .field("registry", &*self.registry);
77        #[cfg(feature = "tts")]
78        d.field("tts_resident", &self.tts_pool.resident_len());
79        d.finish_non_exhaustive()
80    }
81}
82
83impl AurumEngine {
84    /// Build from an already-validated config with default governor settings.
85    pub fn new(config: ValidatedConfig) -> Self {
86        Self::with_governor(config, GovernorConfig::default())
87            .expect("default GovernorConfig is always valid")
88    }
89
90    /// Build with an explicit governor profile (mobile/server/custom).
91    ///
92    /// Validates `gov` before construction (JOE-1917 / F-004).
93    pub fn with_governor(config: ValidatedConfig, gov: GovernorConfig) -> Result<Self> {
94        let registry = ProviderRegistry::builtin()
95            .expect("builtin provider registry must construct (compile-time product factories)");
96        Self::with_governor_and_registry(config, gov, registry)
97    }
98
99    /// Build with an explicit governor and provider registry (tests / embedders).
100    pub fn with_governor_and_registry(
101        config: ValidatedConfig,
102        gov: GovernorConfig,
103        registry: ProviderRegistry,
104    ) -> Result<Self> {
105        let governor = Arc::new(ResourceGovernor::try_new(gov)?);
106        Ok(Self {
107            config,
108            governor,
109            metrics: Arc::new(Metrics::engine_local()),
110            stt_pool: Arc::new(SttContextPool::new()),
111            #[cfg(feature = "tts")]
112            tts_pool: Arc::new(TtsSessionPool::new()),
113            registry: Arc::new(registry),
114            closed: AtomicBool::new(false),
115        })
116    }
117
118    /// Load config from the default file/env path and validate.
119    pub fn load() -> Result<Self> {
120        Ok(Self::new(ValidatedConfig::load()?))
121    }
122
123    /// Load from an explicit config file path (must exist).
124    pub fn load_from_required(path: &std::path::Path) -> Result<Self> {
125        Ok(Self::new(ValidatedConfig::load_from_required(path)?))
126    }
127
128    /// Validate a raw [`Config`] and wrap it.
129    pub fn from_config(cfg: Config) -> Result<Self> {
130        Ok(Self::new(ValidatedConfig::try_from_config(cfg)?))
131    }
132
133    pub fn config(&self) -> &Config {
134        self.config.as_ref()
135    }
136
137    pub fn validated_config(&self) -> &ValidatedConfig {
138        &self.config
139    }
140
141    pub fn governor(&self) -> &Arc<ResourceGovernor> {
142        &self.governor
143    }
144
145    pub fn metrics(&self) -> &Arc<Metrics> {
146        &self.metrics
147    }
148
149    pub fn stt_pool(&self) -> &Arc<SttContextPool> {
150        &self.stt_pool
151    }
152
153    #[cfg(feature = "tts")]
154    pub fn tts_pool(&self) -> &Arc<TtsSessionPool> {
155        &self.tts_pool
156    }
157
158    /// Immutable provider registry owned by this engine (JOE-1938).
159    pub fn registry(&self) -> &ProviderRegistry {
160        &self.registry
161    }
162
163    pub fn is_closed(&self) -> bool {
164        self.closed.load(Ordering::SeqCst)
165    }
166
167    fn ensure_open(&self) -> Result<()> {
168        if self.is_closed() {
169            return Err(UserError::Other {
170                message: "AurumEngine is closed".into(),
171            }
172            .into());
173        }
174        Ok(())
175    }
176
177    /// Assemble a single-provider build context from engine config + pools (JOE-1938).
178    ///
179    /// Secrets are scoped to `id` only via [`Config::provider_secret`].
180    pub fn build_context_for(&self, id: &ProviderId) -> Result<ProviderBuildContext> {
181        self.build_context_for_with(id, ProviderResolveOptions::default())
182    }
183
184    /// Build context with optional CLI/library overrides.
185    pub fn build_context_for_with(
186        &self,
187        id: &ProviderId,
188        opts: ProviderResolveOptions,
189    ) -> Result<ProviderBuildContext> {
190        self.ensure_open()?;
191        let cfg = self.config.as_ref();
192        let local_only = opts.local_only.unwrap_or(cfg.local_only);
193        let stt_mode = match opts.stt_mode {
194            Some(m) => m,
195            None => OpenRouterSttMode::parse(&cfg.openrouter_stt_mode)?,
196        };
197
198        let mut ctx = ProviderBuildContext::new(self.cache_dir().to_path_buf())
199            .with_local_only(local_only)
200            .with_api_key(cfg.provider_secret(id))
201            .with_show_progress(opts.show_progress)
202            .with_stt_mode(stt_mode)
203            .with_tts_max_chars(Some(cfg.tts_max_chars))
204            .with_stt_pool(Arc::clone(&self.stt_pool))
205            .with_governor(Arc::clone(&self.governor))
206            .with_metrics(Arc::clone(&self.metrics));
207
208        #[cfg(feature = "tts")]
209        {
210            ctx = ctx.with_tts_pool(Arc::clone(&self.tts_pool));
211        }
212
213        // Endpoint knobs only for the selected provider id (no multi-vendor bag).
214        match id.as_str() {
215            "openrouter" => {
216                ctx = ctx
217                    .with_base_url(Some(cfg.openrouter_base_url.clone()))
218                    .with_allow_custom_endpoint(cfg.openrouter_allow_custom_endpoint)
219                    .with_use_system_proxy(cfg.openrouter_use_system_proxy);
220            }
221            "openai" => {
222                if let Some(url) = cfg.providers.openai.base_url.clone() {
223                    ctx = ctx.with_base_url(Some(url));
224                }
225            }
226            "elevenlabs" => {
227                if let Some(url) = cfg.providers.elevenlabs.base_url.clone() {
228                    ctx = ctx.with_base_url(Some(url));
229                }
230            }
231            "xai" => {
232                if let Some(url) = cfg.providers.xai.base_url.clone() {
233                    ctx = ctx.with_base_url(Some(url));
234                }
235            }
236            _ => {}
237        }
238
239        Ok(ctx)
240    }
241
242    /// Construct an STT provider via the engine registry (JOE-1938).
243    pub fn stt_provider(&self, id: &ProviderId) -> Result<Arc<dyn TranscriptionProvider>> {
244        self.stt_provider_with(id, ProviderResolveOptions::default())
245    }
246
247    pub fn stt_provider_with(
248        &self,
249        id: &ProviderId,
250        opts: ProviderResolveOptions,
251    ) -> Result<Arc<dyn TranscriptionProvider>> {
252        self.ensure_open()?;
253        let factory = self.registry.stt_factory(id)?;
254        let ctx = self.build_context_for_with(id, opts)?;
255        factory.build(&ctx)
256    }
257
258    /// Construct a TTS provider via the engine registry (JOE-1938).
259    #[cfg(feature = "tts")]
260    pub fn tts_provider(&self, id: &ProviderId) -> Result<Arc<dyn SynthesisProvider>> {
261        self.tts_provider_with(id, ProviderResolveOptions::default())
262    }
263
264    #[cfg(feature = "tts")]
265    pub fn tts_provider_with(
266        &self,
267        id: &ProviderId,
268        opts: ProviderResolveOptions,
269    ) -> Result<Arc<dyn SynthesisProvider>> {
270        self.ensure_open()?;
271        let factory = self.registry.tts_factory(id)?;
272        let ctx = self.build_context_for_with(id, opts)?;
273        factory.build(&ctx)
274    }
275
276    /// Parse config STT provider string to [`ProviderId`].
277    pub fn stt_provider_id(&self) -> Result<ProviderId> {
278        ProviderId::parse(&self.config.as_ref().provider)
279    }
280
281    /// Parse config TTS provider string to [`ProviderId`].
282    #[cfg(feature = "tts")]
283    pub fn tts_provider_id(&self) -> Result<ProviderId> {
284        ProviderId::parse(&self.config.as_ref().tts_provider)
285    }
286
287    /// Local STT provider bound to this engine's pool and governor (JOE-1784).
288    ///
289    /// Equivalent to [`Self::stt_provider`] with [`ProviderId::local`] plus
290    /// local-only convenience defaults.
291    pub fn local_whisper(&self) -> Result<LocalWhisperProvider> {
292        self.ensure_open()?;
293        Ok(LocalWhisperProvider::with_runtime(
294            self.cache_dir().to_path_buf(),
295            Arc::clone(&self.stt_pool),
296            Arc::clone(&self.governor),
297        )
298        .with_progress(false))
299    }
300
301    /// Local TTS provider bound to this engine's pool and governor (JOE-1784).
302    #[cfg(feature = "tts")]
303    pub fn local_tts(&self) -> Result<LocalTtsProvider> {
304        self.ensure_open()?;
305        Ok(LocalTtsProvider::with_runtime(
306            self.cache_dir().to_path_buf(),
307            Arc::clone(&self.tts_pool),
308            Arc::clone(&self.governor),
309        )
310        .with_progress(false)
311        .with_max_chars(self.config.as_ref().tts_max_chars))
312    }
313
314    /// High-level STT from a prepared [`AudioInput`] via config `provider` (JOE-1787 / JOE-1938).
315    ///
316    /// Compatibility wrapper around [`Self::transcribe_request`] with a default
317    /// [`OperationOptions`] (cancel taken from `options` when present).
318    pub async fn transcribe(
319        &self,
320        input: &AudioInput,
321        options: &TranscriptionOptions,
322    ) -> Result<TranscriptionResult> {
323        let mut op = OperationOptions::new();
324        if let Some(ref c) = options.cancel {
325            op = op.with_cancel(c.clone());
326        }
327        let request = TranscriptionRequest {
328            model: options.model.clone(),
329            language: options.language.clone(),
330            timestamps: options.timestamps,
331            operation: op,
332        };
333        self.transcribe_request(input, request).await
334    }
335
336    /// STT via the typed request contract (JOE-2221 / v0.0.23 P1c).
337    ///
338    /// Drives provider execution from [`TranscriptionRequest`], preserves cancel /
339    /// deadline / request id through [`OpContext`], and records a single terminal
340    /// outcome via [`TerminalGuard`] (P1d).
341    pub async fn transcribe_request(
342        &self,
343        input: &AudioInput,
344        request: TranscriptionRequest,
345    ) -> Result<TranscriptionResult> {
346        self.ensure_open()?;
347        request.validate()?;
348        let (options, op) = request.into_options_and_context();
349        op.check()?;
350        self.run_stt(input, &options, op).await
351    }
352
353    async fn run_stt(
354        &self,
355        input: &AudioInput,
356        options: &TranscriptionOptions,
357        op: OpContext,
358    ) -> Result<TranscriptionResult> {
359        let id = self.stt_provider_id()?;
360        let mut guard = TerminalGuard::start(Arc::clone(&self.metrics), op.request_id, OpKind::Stt);
361        let decoded_bytes = (input.len() as u64).saturating_mul(4);
362        self.metrics.record_decoded_bytes(decoded_bytes);
363        self.metrics.emit(
364            OpEvent::stage(
365                op.request_id,
366                OpKind::Stt,
367                OpStage::Inference,
368                self.metrics.scope(),
369            )
370            .with_provider(id.as_str())
371            .with_model(options.model.clone())
372            .with_decoded_bytes(decoded_bytes),
373        );
374
375        let provider = match self.stt_provider(&id) {
376            Ok(p) => p,
377            Err(e) => {
378                self.finish_guard(&mut guard, &e);
379                return Err(e);
380            }
381        };
382        if let Err(e) = op.check() {
383            self.finish_guard(&mut guard, &e);
384            return Err(e);
385        }
386        // Inject the parent OpContext so remote/long-form paths share one deadline.
387        let mut options = options.clone();
388        options.op = Some(op.clone());
389        options.cancel = Some(op.cancel.clone());
390        let out = provider.transcribe(input, &options).await;
391        match &out {
392            Ok(_) => {
393                guard.finish(TerminalCategory::Completed, false);
394            }
395            Err(e) => {
396                self.finish_guard(&mut guard, e);
397            }
398        }
399        out
400    }
401
402    fn finish_guard(&self, guard: &mut TerminalGuard, err: &crate::error::TranscriptionError) {
403        let cat = match err.error_category() {
404            ErrorCategory::Cancelled => TerminalCategory::Cancelled,
405            ErrorCategory::DeadlineExceeded => TerminalCategory::Deadline,
406            ErrorCategory::BusyOverloaded => TerminalCategory::Overload,
407            _ => TerminalCategory::Failed,
408        };
409        guard.finish(cat, err.retryable());
410    }
411
412    /// High-level STT from mono PCM @ whisper sample rate (JOE-1787 / JOE-1938).
413    ///
414    /// Builds a validated [`AudioInput`] then routes through the registry so
415    /// remote providers share the same path as file-based STT.
416    pub async fn transcribe_pcm(
417        &self,
418        samples: &[f32],
419        options: &TranscriptionOptions,
420    ) -> Result<TranscriptionResult> {
421        let input = AudioInput::from_pcm_slice(samples, crate::audio::WHISPER_SAMPLE_RATE)?;
422        self.transcribe(&input, options).await
423    }
424
425    /// Preload a local STT model into **this** engine's pool.
426    pub async fn preload_stt(&self, model: &str) -> Result<std::path::PathBuf> {
427        self.ensure_open()?;
428        self.local_whisper()?.preload(model).await
429    }
430
431    /// High-level TTS synthesis via config `tts_provider` (JOE-1787 / JOE-1938).
432    #[cfg(feature = "tts")]
433    pub async fn synthesize(
434        &self,
435        text: &str,
436        options: &SynthesisOptions,
437    ) -> Result<SynthesisResult> {
438        let mut options = options.clone();
439        if options.op.is_none() {
440            options.op = Some(OpContext::from_optional_cancel(options.cancel.clone()));
441        }
442        self.run_tts(text, options).await
443    }
444
445    /// TTS via the typed request contract (JOE-2221 / v0.0.23 P1c).
446    #[cfg(feature = "tts")]
447    pub async fn synthesize_request(
448        &self,
449        text: &str,
450        request: crate::sdk::SynthesisRequest,
451    ) -> Result<SynthesisResult> {
452        self.ensure_open()?;
453        request.validate()?;
454        let (options, _op) = request.into_options_and_context();
455        self.run_tts(text, options).await
456    }
457
458    #[cfg(feature = "tts")]
459    async fn run_tts(&self, text: &str, mut options: SynthesisOptions) -> Result<SynthesisResult> {
460        self.ensure_open()?;
461        // Propagate engine local_only and ensure OpContext is present.
462        if self.config.as_ref().local_only {
463            options.local_only = true;
464        }
465        if options.op.is_none() {
466            options.op = Some(OpContext::from_optional_cancel(options.cancel.clone()));
467        }
468        let op = options.resolve_op_context();
469        options.op = Some(op.clone());
470        options.cancel = Some(op.cancel.clone());
471        op.check()?;
472
473        let id = self.tts_provider_id()?;
474        let mut guard = TerminalGuard::start(Arc::clone(&self.metrics), op.request_id, OpKind::Tts);
475        let chars = text.chars().count() as u64;
476        self.metrics.record_tts_chars(chars);
477        self.metrics.emit(
478            OpEvent::stage(
479                op.request_id,
480                OpKind::Tts,
481                OpStage::Inference,
482                self.metrics.scope(),
483            )
484            .with_provider(id.as_str())
485            .with_model(options.model.clone())
486            .with_encoded_bytes(chars),
487        );
488
489        let provider = match self.tts_provider(&id) {
490            Ok(p) => p,
491            Err(e) => {
492                self.finish_guard(&mut guard, &e);
493                return Err(e);
494            }
495        };
496        if let Err(e) = op.check() {
497            self.finish_guard(&mut guard, &e);
498            return Err(e);
499        }
500        let out = provider.synthesize(text, &options).await;
501        match &out {
502            Ok(r) => {
503                if r.chunk_count > 0 {
504                    self.metrics.record_tts_chunks(r.chunk_count as u64);
505                }
506                guard.finish(TerminalCategory::Completed, false);
507            }
508            Err(e) => {
509                self.finish_guard(&mut guard, e);
510            }
511        }
512        out
513    }
514
515    /// Drop idle model residency in **this** engine's pools (JOE-1784).
516    pub fn clear_model_caches(&self) {
517        self.stt_pool.clear();
518        #[cfg(feature = "tts")]
519        self.tts_pool.clear();
520    }
521
522    /// Mark the engine closed and clear idle model caches.
523    ///
524    /// Does not touch process-global pools used by non-engine providers.
525    pub fn shutdown(&self) {
526        self.closed.store(true, Ordering::SeqCst);
527        self.clear_model_caches();
528    }
529
530    /// Read-only doctor report using this engine's config.
531    pub fn doctor(&self) -> DoctorReport {
532        run_doctor(self.config.as_ref())
533    }
534
535    /// Privacy-safe support bundle using this engine's config and **engine** metrics.
536    pub fn support_bundle(&self, user_notes: Option<String>) -> SupportBundle {
537        let mut bundle = build_support_bundle(self.config.as_ref(), user_notes);
538        bundle.metrics = self.metrics.snapshot();
539        bundle.redaction_notes.push(format!(
540            "metrics are engine-local; stt_resident={}{}",
541            self.stt_pool.resident_len(),
542            {
543                #[cfg(feature = "tts")]
544                {
545                    format!(", tts_resident={}", self.tts_pool.resident_len())
546                }
547                #[cfg(not(feature = "tts"))]
548                {
549                    String::new()
550                }
551            }
552        ));
553        bundle
554    }
555
556    pub fn metrics_snapshot(&self) -> MetricsSnapshot {
557        self.metrics.snapshot()
558    }
559
560    /// Cache directory from validated config.
561    pub fn cache_dir(&self) -> &std::path::Path {
562        &self.config.as_ref().cache_dir
563    }
564}
565
566impl Drop for AurumEngine {
567    fn drop(&mut self) {
568        self.closed.store(true, Ordering::SeqCst);
569        self.clear_model_caches();
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::provider_platform::preflight_stt_with_registry;
577
578    #[tokio::test]
579    async fn transcribe_request_respects_deadline_and_records_terminal() {
580        use crate::audio::AudioInput;
581        use crate::sdk::TranscriptionRequest;
582        use std::time::{Duration, Instant};
583
584        let e = AurumEngine::load().unwrap();
585        let sink = Arc::new(crate::observability::BoundedEventSink::new(32));
586        e.metrics().set_event_sink(Some(
587            sink.clone() as Arc<dyn crate::observability::EventSink>
588        ));
589        // Past deadline → fails closed before provider work.
590        let request = TranscriptionRequest::new("tiny-q5_1").operation(
591            OperationOptions::new().with_deadline(Instant::now() - Duration::from_secs(1)),
592        );
593        let audio =
594            AudioInput::from_pcm(vec![0.0f32; 1600], crate::audio::WHISPER_SAMPLE_RATE).unwrap();
595        let err = e.transcribe_request(&audio, request).await.unwrap_err();
596        assert_eq!(err.error_category(), ErrorCategory::DeadlineExceeded);
597        // TerminalGuard may not start if check fails first — ops_started can be 0.
598        // When deadline is checked before TerminalGuard, no metrics start is expected.
599        let _ = sink.drain();
600        let _ = err;
601    }
602
603    #[tokio::test]
604    async fn transcribe_request_wires_metrics_on_provider_error() {
605        use crate::audio::AudioInput;
606        use crate::sdk::TranscriptionRequest;
607
608        let e = AurumEngine::load().unwrap();
609        let sink = Arc::new(crate::observability::BoundedEventSink::new(64));
610        e.metrics().set_event_sink(Some(
611            sink.clone() as Arc<dyn crate::observability::EventSink>
612        ));
613        // Empty model rejected by request validation before guard.
614        let request = TranscriptionRequest::new("");
615        let audio =
616            AudioInput::from_pcm(vec![0.0f32; 1600], crate::audio::WHISPER_SAMPLE_RATE).unwrap();
617        assert!(e.transcribe_request(&audio, request).await.is_err());
618        assert_eq!(e.metrics_snapshot().ops_started, 0);
619
620        // Missing model file will fail during provider path after TerminalGuard starts.
621        let request = TranscriptionRequest::new("definitely-not-a-real-model-xyz");
622        let err = e.transcribe_request(&audio, request).await;
623        assert!(err.is_err());
624        let snap = e.metrics_snapshot();
625        assert!(snap.ops_started >= 1);
626        assert!(snap.ops_failed >= 1 || snap.ops_completed >= 1);
627        let events = sink.drain();
628        assert!(
629            events.iter().any(|ev| ev.stage == OpStage::Start),
630            "expected Start event"
631        );
632        assert!(
633            events
634                .iter()
635                .any(|ev| ev.stage == OpStage::Terminal || ev.terminal.is_some()),
636            "expected Terminal event"
637        );
638        assert!(snap.decoded_bytes_total > 0);
639    }
640
641    #[test]
642    fn independent_engines_have_independent_metrics_and_pools() {
643        let a = AurumEngine::load().unwrap();
644        let b = AurumEngine::load().unwrap();
645        a.metrics().record_start();
646        a.metrics()
647            .record_complete(std::time::Duration::from_millis(1));
648        assert_eq!(a.metrics_snapshot().ops_started, 1);
649        assert_eq!(b.metrics_snapshot().ops_started, 0);
650        assert!(!std::ptr::eq(
651            Arc::as_ptr(a.governor()),
652            Arc::as_ptr(b.governor())
653        ));
654        assert!(!std::ptr::eq(
655            Arc::as_ptr(a.stt_pool()),
656            Arc::as_ptr(b.stt_pool())
657        ));
658        #[cfg(feature = "tts")]
659        assert!(!std::ptr::eq(
660            Arc::as_ptr(a.tts_pool()),
661            Arc::as_ptr(b.tts_pool())
662        ));
663        // Process-global default pool is distinct from engine pools.
664        let process = crate::providers::local::process_global_stt_pool();
665        assert!(!std::ptr::eq(
666            Arc::as_ptr(a.stt_pool()),
667            Arc::as_ptr(&process)
668        ));
669    }
670
671    #[test]
672    fn shutdown_flags_closed_and_rejects_local_whisper() {
673        let e = AurumEngine::load().unwrap();
674        assert!(!e.is_closed());
675        e.shutdown();
676        assert!(e.is_closed());
677        assert!(e.local_whisper().is_err());
678        assert!(e.stt_provider(&ProviderId::local()).is_err());
679    }
680
681    #[test]
682    fn doctor_and_support_bundle_work() {
683        let e = AurumEngine::load().unwrap();
684        let d = e.doctor();
685        assert!(!d.checks.is_empty());
686        let b = e.support_bundle(None);
687        assert_eq!(b.schema_version, crate::support::SUPPORT_BUNDLE_VERSION);
688        let json = b.to_json_pretty().unwrap();
689        assert!(json.contains("engine-local") || json.contains("stt_resident"));
690    }
691
692    #[test]
693    fn local_whisper_uses_engine_pool() {
694        let e = AurumEngine::load().unwrap();
695        let p = e.local_whisper().unwrap();
696        assert!(std::ptr::eq(
697            Arc::as_ptr(p.pool()),
698            Arc::as_ptr(e.stt_pool())
699        ));
700        assert!(std::ptr::eq(
701            Arc::as_ptr(p.governor()),
702            Arc::as_ptr(e.governor())
703        ));
704    }
705
706    #[test]
707    fn registry_stt_local_builds() {
708        let e = AurumEngine::load().unwrap();
709        let p = e.stt_provider(&ProviderId::local()).unwrap();
710        assert_eq!(p.name(), "local");
711    }
712
713    #[test]
714    fn registry_unknown_stt_fails_closed() {
715        let e = AurumEngine::load().unwrap();
716        // elevenlabs is TTS-only; no STT factory.
717        let err = match e.stt_provider(&ProviderId::must("elevenlabs")) {
718            Ok(_) => panic!("expected unknown STT factory error"),
719            Err(e) => e,
720        };
721        assert!(err.to_string().contains("elevenlabs") || err.to_string().contains("provider"));
722    }
723
724    #[test]
725    fn openai_stt_builds_with_key() {
726        let mut cfg = Config::load().unwrap();
727        cfg.providers.openai.api_key = Some(crate::secret::SecretString::new("sk-test-openai-key"));
728        let e = AurumEngine::from_config(cfg).unwrap();
729        let p = e.stt_provider(&ProviderId::must("openai")).unwrap();
730        assert_eq!(p.name(), "openai");
731    }
732
733    #[test]
734    fn openrouter_local_only_rejected() {
735        let mut cfg = Config::load().unwrap();
736        cfg.local_only = true;
737        let e = AurumEngine::from_config(cfg).unwrap();
738        let err = match e.stt_provider_with(
739            &ProviderId::openrouter(),
740            ProviderResolveOptions {
741                local_only: Some(true),
742                ..Default::default()
743            },
744        ) {
745            Ok(_) => panic!("expected local_only rejection"),
746            Err(e) => e,
747        };
748        assert!(
749            err.to_string().contains("local_only")
750                || err.to_string().contains("network")
751                || err.to_string().contains("remote")
752        );
753    }
754
755    #[test]
756    fn openrouter_missing_key_fails() {
757        let mut cfg = Config::load().unwrap();
758        cfg.openrouter_api_key = None;
759        let e = AurumEngine::from_config(cfg).unwrap();
760        let err = match e.stt_provider(&ProviderId::openrouter()) {
761            Ok(_) => panic!("expected missing key"),
762            Err(e) => e,
763        };
764        let s = err.to_string().to_ascii_lowercase();
765        assert!(
766            s.contains("api") || s.contains("key") || s.contains("auth"),
767            "unexpected: {s}"
768        );
769    }
770
771    #[test]
772    fn build_context_scopes_secret_to_id() {
773        let mut cfg = Config::load().unwrap();
774        cfg.openrouter_api_key = Some(crate::secret::SecretString::new("sk-or-test-secret"));
775        let e = AurumEngine::from_config(cfg).unwrap();
776        let local_ctx = e.build_context_for(&ProviderId::local()).unwrap();
777        assert!(!local_ctx.has_api_key());
778        let or_ctx = e.build_context_for(&ProviderId::openrouter()).unwrap();
779        assert!(or_ctx.has_api_key());
780        let dbg = format!("{or_ctx:?}");
781        assert!(!dbg.contains("sk-or-test"));
782    }
783
784    #[test]
785    fn preflight_openrouter_local_only() {
786        let e = AurumEngine::load().unwrap();
787        let err = preflight_stt_with_registry(
788            e.registry(),
789            &ProviderId::openrouter(),
790            "openai/whisper-large-v3",
791            false,
792            true,
793            OpenRouterSttMode::Auto,
794        )
795        .unwrap_err();
796        assert!(err.to_string().contains("network") || err.to_string().contains("local"));
797    }
798
799    #[cfg(feature = "tts")]
800    #[test]
801    fn registry_tts_local_builds() {
802        let e = AurumEngine::load().unwrap();
803        let p = e.tts_provider(&ProviderId::local()).unwrap();
804        assert_eq!(p.name(), "local");
805    }
806
807    #[test]
808    fn shutdown_rejects_stt_provider() {
809        let e = AurumEngine::load().unwrap();
810        e.shutdown();
811        assert!(e.stt_provider(&ProviderId::local()).is_err());
812        assert!(e.build_context_for(&ProviderId::local()).is_err());
813    }
814}