1use 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
55pub 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 pub fn new(config: ValidatedConfig) -> Self {
84 Self::with_governor(config, GovernorConfig::default())
85 .expect("default GovernorConfig is always valid")
86 }
87
88 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 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 pub fn load() -> Result<Self> {
118 Ok(Self::new(ValidatedConfig::load()?))
119 }
120
121 pub fn load_from_required(path: &std::path::Path) -> Result<Self> {
123 Ok(Self::new(ValidatedConfig::load_from_required(path)?))
124 }
125
126 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 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 pub fn build_context_for(&self, id: &ProviderId) -> Result<ProviderBuildContext> {
179 self.build_context_for_with(id, ProviderResolveOptions::default())
180 }
181
182 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 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 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 #[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 pub fn stt_provider_id(&self) -> Result<ProviderId> {
276 ProviderId::parse(&self.config.as_ref().provider)
277 }
278
279 #[cfg(feature = "tts")]
281 pub fn tts_provider_id(&self) -> Result<ProviderId> {
282 ProviderId::parse(&self.config.as_ref().tts_provider)
283 }
284
285 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 #[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 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 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 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 #[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 pub fn clear_model_caches(&self) {
372 self.stt_pool.clear();
373 #[cfg(feature = "tts")]
374 self.tts_pool.clear();
375 }
376
377 pub fn shutdown(&self) {
381 self.closed.store(true, Ordering::SeqCst);
382 self.clear_model_caches();
383 }
384
385 pub fn doctor(&self) -> DoctorReport {
387 run_doctor(self.config.as_ref())
388 }
389
390 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 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 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 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}