1use 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
57pub 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 pub fn new(config: ValidatedConfig) -> Self {
86 Self::with_governor(config, GovernorConfig::default())
87 .expect("default GovernorConfig is always valid")
88 }
89
90 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 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 pub fn load() -> Result<Self> {
120 Ok(Self::new(ValidatedConfig::load()?))
121 }
122
123 pub fn load_from_required(path: &std::path::Path) -> Result<Self> {
125 Ok(Self::new(ValidatedConfig::load_from_required(path)?))
126 }
127
128 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 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 pub fn build_context_for(&self, id: &ProviderId) -> Result<ProviderBuildContext> {
181 self.build_context_for_with(id, ProviderResolveOptions::default())
182 }
183
184 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 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 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 #[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 pub fn stt_provider_id(&self) -> Result<ProviderId> {
278 ProviderId::parse(&self.config.as_ref().provider)
279 }
280
281 #[cfg(feature = "tts")]
283 pub fn tts_provider_id(&self) -> Result<ProviderId> {
284 ProviderId::parse(&self.config.as_ref().tts_provider)
285 }
286
287 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 #[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 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 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 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 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 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 #[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 #[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 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 pub fn clear_model_caches(&self) {
517 self.stt_pool.clear();
518 #[cfg(feature = "tts")]
519 self.tts_pool.clear();
520 }
521
522 pub fn shutdown(&self) {
526 self.closed.store(true, Ordering::SeqCst);
527 self.clear_model_caches();
528 }
529
530 pub fn doctor(&self) -> DoctorReport {
532 run_doctor(self.config.as_ref())
533 }
534
535 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 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 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 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 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 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 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 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}