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::{Result, UserError};
36use crate::observability::{Metrics, MetricsSnapshot};
37use crate::provider_platform::{
38    ProviderBuildContext, ProviderId, ProviderRegistry, ProviderResolveOptions,
39};
40use crate::providers::local::{LocalWhisperProvider, SttContextPool};
41use crate::providers::{
42    OpenRouterSttMode, TranscriptionOptions, TranscriptionProvider, TranscriptionResult,
43};
44use crate::runtime::{GovernorConfig, ResourceGovernor};
45use crate::support::{build_support_bundle, SupportBundle};
46use std::sync::atomic::{AtomicBool, Ordering};
47use std::sync::Arc;
48use std::time::Instant;
49
50#[cfg(feature = "tts")]
51use crate::tts::local::{LocalTtsProvider, TtsSessionPool};
52#[cfg(feature = "tts")]
53use crate::tts::provider::{SynthesisOptions, SynthesisProvider, SynthesisResult};
54
55/// Library-facing engine: validated config + owned governor/metrics/model pools + registry.
56pub struct AurumEngine {
57    config: ValidatedConfig,
58    governor: Arc<ResourceGovernor>,
59    metrics: Arc<Metrics>,
60    stt_pool: Arc<SttContextPool>,
61    #[cfg(feature = "tts")]
62    tts_pool: Arc<TtsSessionPool>,
63    registry: Arc<ProviderRegistry>,
64    closed: AtomicBool,
65}
66
67impl std::fmt::Debug for AurumEngine {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        let mut d = f.debug_struct("AurumEngine");
70        d.field("config", &self.config)
71            .field("closed", &self.closed.load(Ordering::SeqCst))
72            .field("metrics", &self.metrics.snapshot())
73            .field("stt_resident", &self.stt_pool.resident_len())
74            .field("registry", &*self.registry);
75        #[cfg(feature = "tts")]
76        d.field("tts_resident", &self.tts_pool.resident_len());
77        d.finish_non_exhaustive()
78    }
79}
80
81impl AurumEngine {
82    /// Build from an already-validated config with default governor settings.
83    pub fn new(config: ValidatedConfig) -> Self {
84        Self::with_governor(config, GovernorConfig::default())
85            .expect("default GovernorConfig is always valid")
86    }
87
88    /// Build with an explicit governor profile (mobile/server/custom).
89    ///
90    /// Validates `gov` before construction (JOE-1917 / F-004).
91    pub fn with_governor(config: ValidatedConfig, gov: GovernorConfig) -> Result<Self> {
92        let registry = ProviderRegistry::builtin()
93            .expect("builtin provider registry must construct (compile-time product factories)");
94        Self::with_governor_and_registry(config, gov, registry)
95    }
96
97    /// Build with an explicit governor and provider registry (tests / embedders).
98    pub fn with_governor_and_registry(
99        config: ValidatedConfig,
100        gov: GovernorConfig,
101        registry: ProviderRegistry,
102    ) -> Result<Self> {
103        let governor = Arc::new(ResourceGovernor::try_new(gov)?);
104        Ok(Self {
105            config,
106            governor,
107            metrics: Arc::new(Metrics::new()),
108            stt_pool: Arc::new(SttContextPool::new()),
109            #[cfg(feature = "tts")]
110            tts_pool: Arc::new(TtsSessionPool::new()),
111            registry: Arc::new(registry),
112            closed: AtomicBool::new(false),
113        })
114    }
115
116    /// Load config from the default file/env path and validate.
117    pub fn load() -> Result<Self> {
118        Ok(Self::new(ValidatedConfig::load()?))
119    }
120
121    /// Load from an explicit config file path (must exist).
122    pub fn load_from_required(path: &std::path::Path) -> Result<Self> {
123        Ok(Self::new(ValidatedConfig::load_from_required(path)?))
124    }
125
126    /// Validate a raw [`Config`] and wrap it.
127    pub fn from_config(cfg: Config) -> Result<Self> {
128        Ok(Self::new(ValidatedConfig::try_from_config(cfg)?))
129    }
130
131    pub fn config(&self) -> &Config {
132        self.config.as_ref()
133    }
134
135    pub fn validated_config(&self) -> &ValidatedConfig {
136        &self.config
137    }
138
139    pub fn governor(&self) -> &Arc<ResourceGovernor> {
140        &self.governor
141    }
142
143    pub fn metrics(&self) -> &Arc<Metrics> {
144        &self.metrics
145    }
146
147    pub fn stt_pool(&self) -> &Arc<SttContextPool> {
148        &self.stt_pool
149    }
150
151    #[cfg(feature = "tts")]
152    pub fn tts_pool(&self) -> &Arc<TtsSessionPool> {
153        &self.tts_pool
154    }
155
156    /// Immutable provider registry owned by this engine (JOE-1938).
157    pub fn registry(&self) -> &ProviderRegistry {
158        &self.registry
159    }
160
161    pub fn is_closed(&self) -> bool {
162        self.closed.load(Ordering::SeqCst)
163    }
164
165    fn ensure_open(&self) -> Result<()> {
166        if self.is_closed() {
167            return Err(UserError::Other {
168                message: "AurumEngine is closed".into(),
169            }
170            .into());
171        }
172        Ok(())
173    }
174
175    /// Assemble a single-provider build context from engine config + pools (JOE-1938).
176    ///
177    /// Secrets are scoped to `id` only via [`Config::provider_secret`].
178    pub fn build_context_for(&self, id: &ProviderId) -> Result<ProviderBuildContext> {
179        self.build_context_for_with(id, ProviderResolveOptions::default())
180    }
181
182    /// Build context with optional CLI/library overrides.
183    pub fn build_context_for_with(
184        &self,
185        id: &ProviderId,
186        opts: ProviderResolveOptions,
187    ) -> Result<ProviderBuildContext> {
188        self.ensure_open()?;
189        let cfg = self.config.as_ref();
190        let local_only = opts.local_only.unwrap_or(cfg.local_only);
191        let stt_mode = match opts.stt_mode {
192            Some(m) => m,
193            None => OpenRouterSttMode::parse(&cfg.openrouter_stt_mode)?,
194        };
195
196        let mut ctx = ProviderBuildContext::new(self.cache_dir().to_path_buf())
197            .with_local_only(local_only)
198            .with_api_key(cfg.provider_secret(id))
199            .with_show_progress(opts.show_progress)
200            .with_stt_mode(stt_mode)
201            .with_tts_max_chars(Some(cfg.tts_max_chars))
202            .with_stt_pool(Arc::clone(&self.stt_pool))
203            .with_governor(Arc::clone(&self.governor))
204            .with_metrics(Arc::clone(&self.metrics));
205
206        #[cfg(feature = "tts")]
207        {
208            ctx = ctx.with_tts_pool(Arc::clone(&self.tts_pool));
209        }
210
211        // Endpoint knobs only for the selected provider id (no multi-vendor bag).
212        match id.as_str() {
213            "openrouter" => {
214                ctx = ctx
215                    .with_base_url(Some(cfg.openrouter_base_url.clone()))
216                    .with_allow_custom_endpoint(cfg.openrouter_allow_custom_endpoint)
217                    .with_use_system_proxy(cfg.openrouter_use_system_proxy);
218            }
219            "openai" => {
220                if let Some(url) = cfg.providers.openai.base_url.clone() {
221                    ctx = ctx.with_base_url(Some(url));
222                }
223            }
224            "elevenlabs" => {
225                if let Some(url) = cfg.providers.elevenlabs.base_url.clone() {
226                    ctx = ctx.with_base_url(Some(url));
227                }
228            }
229            "xai" => {
230                if let Some(url) = cfg.providers.xai.base_url.clone() {
231                    ctx = ctx.with_base_url(Some(url));
232                }
233            }
234            _ => {}
235        }
236
237        Ok(ctx)
238    }
239
240    /// Construct an STT provider via the engine registry (JOE-1938).
241    pub fn stt_provider(&self, id: &ProviderId) -> Result<Arc<dyn TranscriptionProvider>> {
242        self.stt_provider_with(id, ProviderResolveOptions::default())
243    }
244
245    pub fn stt_provider_with(
246        &self,
247        id: &ProviderId,
248        opts: ProviderResolveOptions,
249    ) -> Result<Arc<dyn TranscriptionProvider>> {
250        self.ensure_open()?;
251        let factory = self.registry.stt_factory(id)?;
252        let ctx = self.build_context_for_with(id, opts)?;
253        factory.build(&ctx)
254    }
255
256    /// Construct a TTS provider via the engine registry (JOE-1938).
257    #[cfg(feature = "tts")]
258    pub fn tts_provider(&self, id: &ProviderId) -> Result<Arc<dyn SynthesisProvider>> {
259        self.tts_provider_with(id, ProviderResolveOptions::default())
260    }
261
262    #[cfg(feature = "tts")]
263    pub fn tts_provider_with(
264        &self,
265        id: &ProviderId,
266        opts: ProviderResolveOptions,
267    ) -> Result<Arc<dyn SynthesisProvider>> {
268        self.ensure_open()?;
269        let factory = self.registry.tts_factory(id)?;
270        let ctx = self.build_context_for_with(id, opts)?;
271        factory.build(&ctx)
272    }
273
274    /// Parse config STT provider string to [`ProviderId`].
275    pub fn stt_provider_id(&self) -> Result<ProviderId> {
276        ProviderId::parse(&self.config.as_ref().provider)
277    }
278
279    /// Parse config TTS provider string to [`ProviderId`].
280    #[cfg(feature = "tts")]
281    pub fn tts_provider_id(&self) -> Result<ProviderId> {
282        ProviderId::parse(&self.config.as_ref().tts_provider)
283    }
284
285    /// Local STT provider bound to this engine's pool and governor (JOE-1784).
286    ///
287    /// Equivalent to [`Self::stt_provider`] with [`ProviderId::local`] plus
288    /// local-only convenience defaults.
289    pub fn local_whisper(&self) -> Result<LocalWhisperProvider> {
290        self.ensure_open()?;
291        Ok(LocalWhisperProvider::with_runtime(
292            self.cache_dir().to_path_buf(),
293            Arc::clone(&self.stt_pool),
294            Arc::clone(&self.governor),
295        )
296        .with_progress(false))
297    }
298
299    /// Local TTS provider bound to this engine's pool and governor (JOE-1784).
300    #[cfg(feature = "tts")]
301    pub fn local_tts(&self) -> Result<LocalTtsProvider> {
302        self.ensure_open()?;
303        Ok(LocalTtsProvider::with_runtime(
304            self.cache_dir().to_path_buf(),
305            Arc::clone(&self.tts_pool),
306            Arc::clone(&self.governor),
307        )
308        .with_progress(false)
309        .with_max_chars(self.config.as_ref().tts_max_chars))
310    }
311
312    /// High-level STT from a prepared [`AudioInput`] via config `provider` (JOE-1787 / JOE-1938).
313    pub async fn transcribe(
314        &self,
315        input: &AudioInput,
316        options: &TranscriptionOptions,
317    ) -> Result<TranscriptionResult> {
318        self.ensure_open()?;
319        self.metrics.record_start();
320        let start = Instant::now();
321        let id = self.stt_provider_id()?;
322        let provider = self.stt_provider(&id)?;
323        let out = provider.transcribe(input, options).await;
324        match &out {
325            Ok(_) => self.metrics.record_complete(start.elapsed()),
326            Err(_) => self.metrics.record_failed(),
327        }
328        out
329    }
330
331    /// High-level STT from mono PCM @ whisper sample rate (JOE-1787 / JOE-1938).
332    ///
333    /// Builds a validated [`AudioInput`] then routes through the registry so
334    /// remote providers share the same path as file-based STT.
335    pub async fn transcribe_pcm(
336        &self,
337        samples: &[f32],
338        options: &TranscriptionOptions,
339    ) -> Result<TranscriptionResult> {
340        let input = AudioInput::from_pcm_slice(samples, crate::audio::WHISPER_SAMPLE_RATE)?;
341        self.transcribe(&input, options).await
342    }
343
344    /// Preload a local STT model into **this** engine's pool.
345    pub async fn preload_stt(&self, model: &str) -> Result<std::path::PathBuf> {
346        self.ensure_open()?;
347        self.local_whisper()?.preload(model).await
348    }
349
350    /// High-level TTS synthesis via config `tts_provider` (JOE-1787 / JOE-1938).
351    #[cfg(feature = "tts")]
352    pub async fn synthesize(
353        &self,
354        text: &str,
355        options: &SynthesisOptions,
356    ) -> Result<SynthesisResult> {
357        self.ensure_open()?;
358        self.metrics.record_start();
359        let start = Instant::now();
360        let id = self.tts_provider_id()?;
361        let provider = self.tts_provider(&id)?;
362        let out = provider.synthesize(text, options).await;
363        match &out {
364            Ok(_) => self.metrics.record_complete(start.elapsed()),
365            Err(_) => self.metrics.record_failed(),
366        }
367        out
368    }
369
370    /// Drop idle model residency in **this** engine's pools (JOE-1784).
371    pub fn clear_model_caches(&self) {
372        self.stt_pool.clear();
373        #[cfg(feature = "tts")]
374        self.tts_pool.clear();
375    }
376
377    /// Mark the engine closed and clear idle model caches.
378    ///
379    /// Does not touch process-global pools used by non-engine providers.
380    pub fn shutdown(&self) {
381        self.closed.store(true, Ordering::SeqCst);
382        self.clear_model_caches();
383    }
384
385    /// Read-only doctor report using this engine's config.
386    pub fn doctor(&self) -> DoctorReport {
387        run_doctor(self.config.as_ref())
388    }
389
390    /// Privacy-safe support bundle using this engine's config and **engine** metrics.
391    pub fn support_bundle(&self, user_notes: Option<String>) -> SupportBundle {
392        let mut bundle = build_support_bundle(self.config.as_ref(), user_notes);
393        bundle.metrics = self.metrics.snapshot();
394        bundle.redaction_notes.push(format!(
395            "metrics are engine-local; stt_resident={}{}",
396            self.stt_pool.resident_len(),
397            {
398                #[cfg(feature = "tts")]
399                {
400                    format!(", tts_resident={}", self.tts_pool.resident_len())
401                }
402                #[cfg(not(feature = "tts"))]
403                {
404                    String::new()
405                }
406            }
407        ));
408        bundle
409    }
410
411    pub fn metrics_snapshot(&self) -> MetricsSnapshot {
412        self.metrics.snapshot()
413    }
414
415    /// Cache directory from validated config.
416    pub fn cache_dir(&self) -> &std::path::Path {
417        &self.config.as_ref().cache_dir
418    }
419}
420
421impl Drop for AurumEngine {
422    fn drop(&mut self) {
423        self.closed.store(true, Ordering::SeqCst);
424        self.clear_model_caches();
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::provider_platform::preflight_stt_with_registry;
432
433    #[test]
434    fn independent_engines_have_independent_metrics_and_pools() {
435        let a = AurumEngine::load().unwrap();
436        let b = AurumEngine::load().unwrap();
437        a.metrics().record_start();
438        a.metrics()
439            .record_complete(std::time::Duration::from_millis(1));
440        assert_eq!(a.metrics_snapshot().ops_started, 1);
441        assert_eq!(b.metrics_snapshot().ops_started, 0);
442        assert!(!std::ptr::eq(
443            Arc::as_ptr(a.governor()),
444            Arc::as_ptr(b.governor())
445        ));
446        assert!(!std::ptr::eq(
447            Arc::as_ptr(a.stt_pool()),
448            Arc::as_ptr(b.stt_pool())
449        ));
450        #[cfg(feature = "tts")]
451        assert!(!std::ptr::eq(
452            Arc::as_ptr(a.tts_pool()),
453            Arc::as_ptr(b.tts_pool())
454        ));
455        // Process-global default pool is distinct from engine pools.
456        let process = crate::providers::local::process_global_stt_pool();
457        assert!(!std::ptr::eq(
458            Arc::as_ptr(a.stt_pool()),
459            Arc::as_ptr(&process)
460        ));
461    }
462
463    #[test]
464    fn shutdown_flags_closed_and_rejects_local_whisper() {
465        let e = AurumEngine::load().unwrap();
466        assert!(!e.is_closed());
467        e.shutdown();
468        assert!(e.is_closed());
469        assert!(e.local_whisper().is_err());
470        assert!(e.stt_provider(&ProviderId::local()).is_err());
471    }
472
473    #[test]
474    fn doctor_and_support_bundle_work() {
475        let e = AurumEngine::load().unwrap();
476        let d = e.doctor();
477        assert!(!d.checks.is_empty());
478        let b = e.support_bundle(None);
479        assert_eq!(b.schema_version, crate::support::SUPPORT_BUNDLE_VERSION);
480        let json = b.to_json_pretty().unwrap();
481        assert!(json.contains("engine-local") || json.contains("stt_resident"));
482    }
483
484    #[test]
485    fn local_whisper_uses_engine_pool() {
486        let e = AurumEngine::load().unwrap();
487        let p = e.local_whisper().unwrap();
488        assert!(std::ptr::eq(
489            Arc::as_ptr(p.pool()),
490            Arc::as_ptr(e.stt_pool())
491        ));
492        assert!(std::ptr::eq(
493            Arc::as_ptr(p.governor()),
494            Arc::as_ptr(e.governor())
495        ));
496    }
497
498    #[test]
499    fn registry_stt_local_builds() {
500        let e = AurumEngine::load().unwrap();
501        let p = e.stt_provider(&ProviderId::local()).unwrap();
502        assert_eq!(p.name(), "local");
503    }
504
505    #[test]
506    fn registry_unknown_stt_fails_closed() {
507        let e = AurumEngine::load().unwrap();
508        // elevenlabs is TTS-only; no STT factory.
509        let err = match e.stt_provider(&ProviderId::must("elevenlabs")) {
510            Ok(_) => panic!("expected unknown STT factory error"),
511            Err(e) => e,
512        };
513        assert!(err.to_string().contains("elevenlabs") || err.to_string().contains("provider"));
514    }
515
516    #[test]
517    fn openai_stt_builds_with_key() {
518        let mut cfg = Config::load().unwrap();
519        cfg.providers.openai.api_key = Some(crate::secret::SecretString::new("sk-test-openai-key"));
520        let e = AurumEngine::from_config(cfg).unwrap();
521        let p = e.stt_provider(&ProviderId::must("openai")).unwrap();
522        assert_eq!(p.name(), "openai");
523    }
524
525    #[test]
526    fn openrouter_local_only_rejected() {
527        let mut cfg = Config::load().unwrap();
528        cfg.local_only = true;
529        let e = AurumEngine::from_config(cfg).unwrap();
530        let err = match e.stt_provider_with(
531            &ProviderId::openrouter(),
532            ProviderResolveOptions {
533                local_only: Some(true),
534                ..Default::default()
535            },
536        ) {
537            Ok(_) => panic!("expected local_only rejection"),
538            Err(e) => e,
539        };
540        assert!(
541            err.to_string().contains("local_only")
542                || err.to_string().contains("network")
543                || err.to_string().contains("remote")
544        );
545    }
546
547    #[test]
548    fn openrouter_missing_key_fails() {
549        let mut cfg = Config::load().unwrap();
550        cfg.openrouter_api_key = None;
551        let e = AurumEngine::from_config(cfg).unwrap();
552        let err = match e.stt_provider(&ProviderId::openrouter()) {
553            Ok(_) => panic!("expected missing key"),
554            Err(e) => e,
555        };
556        let s = err.to_string().to_ascii_lowercase();
557        assert!(
558            s.contains("api") || s.contains("key") || s.contains("auth"),
559            "unexpected: {s}"
560        );
561    }
562
563    #[test]
564    fn build_context_scopes_secret_to_id() {
565        let mut cfg = Config::load().unwrap();
566        cfg.openrouter_api_key = Some(crate::secret::SecretString::new("sk-or-test-secret"));
567        let e = AurumEngine::from_config(cfg).unwrap();
568        let local_ctx = e.build_context_for(&ProviderId::local()).unwrap();
569        assert!(!local_ctx.has_api_key());
570        let or_ctx = e.build_context_for(&ProviderId::openrouter()).unwrap();
571        assert!(or_ctx.has_api_key());
572        let dbg = format!("{or_ctx:?}");
573        assert!(!dbg.contains("sk-or-test"));
574    }
575
576    #[test]
577    fn preflight_openrouter_local_only() {
578        let e = AurumEngine::load().unwrap();
579        let err = preflight_stt_with_registry(
580            e.registry(),
581            &ProviderId::openrouter(),
582            "openai/whisper-large-v3",
583            false,
584            true,
585            OpenRouterSttMode::Auto,
586        )
587        .unwrap_err();
588        assert!(err.to_string().contains("network") || err.to_string().contains("local"));
589    }
590
591    #[cfg(feature = "tts")]
592    #[test]
593    fn registry_tts_local_builds() {
594        let e = AurumEngine::load().unwrap();
595        let p = e.tts_provider(&ProviderId::local()).unwrap();
596        assert_eq!(p.name(), "local");
597    }
598
599    #[test]
600    fn shutdown_rejects_stt_provider() {
601        let e = AurumEngine::load().unwrap();
602        e.shutdown();
603        assert!(e.stt_provider(&ProviderId::local()).is_err());
604        assert!(e.build_context_for(&ProviderId::local()).is_err());
605    }
606}