1use crate::error::{Result, UserError};
18use crate::provider_platform::ProviderId;
19use crate::providers::OpenRouterSttMode;
20use serde::{Deserialize, Serialize};
21
22pub const CAPABILITY_SCHEMA_VERSION: u32 = 1;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum SttBackendClass {
32 Asr,
33 LlmAssisted,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum DescriptorFreshness {
44 #[default]
46 Static,
47 Reviewed,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum VoiceModel {
55 FixedAliases,
57 ProviderVoiceIds,
59 None,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct ProviderCapabilities {
66 pub schema_version: u32,
67 pub provider: String,
68 pub model: String,
69 pub operation: CapabilityOperation,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub stt_backend: Option<SttBackendClass>,
73 pub timestamps_reliable: bool,
74 pub languages: Vec<String>,
75 pub max_duration_secs: Option<f64>,
76 pub max_upload_bytes: Option<u64>,
77 pub max_text_chars: Option<usize>,
78 pub supports_cancellation: bool,
79 pub requires_network: bool,
80 pub local_only_ok: bool,
81 pub output_formats: Vec<String>,
83 pub notes: Vec<String>,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub voice_model: Option<VoiceModel>,
89 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
91 pub supports_voice_ids: bool,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub accepted_audio_formats: Vec<String>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub output_audio_formats: Vec<String>,
98 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
100 pub direct_pcm: bool,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub sample_rates_hz: Vec<u32>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub speaking_rate_min: Option<f32>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub speaking_rate_max: Option<f32>,
110 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112 pub supports_speaking_rate: bool,
113 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
115 pub streaming_advertised: bool,
116 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
121 pub streaming_implemented_by_aurum: bool,
122 #[serde(default, skip_serializing_if = "is_default_freshness")]
124 pub descriptor_freshness: DescriptorFreshness,
125}
126
127fn is_default_freshness(f: &DescriptorFreshness) -> bool {
128 *f == DescriptorFreshness::Static
129}
130
131impl ProviderCapabilities {
132 pub fn with_core(
134 provider: impl Into<String>,
135 model: impl Into<String>,
136 operation: CapabilityOperation,
137 ) -> Self {
138 Self {
139 schema_version: CAPABILITY_SCHEMA_VERSION,
140 provider: provider.into(),
141 model: model.into(),
142 operation,
143 stt_backend: None,
144 timestamps_reliable: false,
145 languages: Vec::new(),
146 max_duration_secs: None,
147 max_upload_bytes: None,
148 max_text_chars: None,
149 supports_cancellation: false,
150 requires_network: false,
151 local_only_ok: true,
152 output_formats: Vec::new(),
153 notes: Vec::new(),
154 voice_model: None,
155 supports_voice_ids: false,
156 accepted_audio_formats: Vec::new(),
157 output_audio_formats: Vec::new(),
158 direct_pcm: false,
159 sample_rates_hz: Vec::new(),
160 speaking_rate_min: None,
161 speaking_rate_max: None,
162 supports_speaking_rate: false,
163 streaming_advertised: false,
164 streaming_implemented_by_aurum: false,
165 descriptor_freshness: DescriptorFreshness::Static,
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum CapabilityOperation {
173 Stt,
174 Tts,
175 Cleanup,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct UnsupportedCapability {
181 pub provider: String,
182 pub model: String,
183 pub reason: String,
184 pub hint: String,
185}
186
187impl std::fmt::Display for UnsupportedCapability {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 write!(
190 f,
191 "unsupported capability for {}/{}: {}\n Hint: {}",
192 self.provider, self.model, self.reason, self.hint
193 )
194 }
195}
196
197impl From<UnsupportedCapability> for crate::error::TranscriptionError {
198 fn from(u: UnsupportedCapability) -> Self {
199 UserError::UnsupportedCapability {
200 provider: u.provider,
201 model: u.model,
202 reason: u.reason,
203 hint: u.hint,
204 }
205 .into()
206 }
207}
208
209pub fn local_whisper_capabilities(model: &str) -> ProviderCapabilities {
211 let mut caps = ProviderCapabilities::with_core("local", model, CapabilityOperation::Stt);
212 caps.stt_backend = Some(SttBackendClass::Asr);
213 caps.timestamps_reliable = true;
214 caps.languages = vec!["auto".into(), "en".into(), "multilingual".into()];
215 caps.max_duration_secs = Some(2.5 * 3600.0);
216 caps.supports_cancellation = true;
217 caps.requires_network = false; caps.local_only_ok = true;
219 caps.output_formats = vec!["txt".into(), "srt".into(), "json".into()];
220 caps.accepted_audio_formats = vec![
221 "wav".into(),
222 "mp3".into(),
223 "flac".into(),
224 "m4a".into(),
225 "ogg".into(),
226 "pcm_f32le".into(),
227 ];
228 caps.direct_pcm = true;
229 caps.sample_rates_hz = vec![16_000];
230 caps.descriptor_freshness = DescriptorFreshness::Static;
231 caps.notes = vec![
232 "Timestamps are engine-derived ASR timings.".into(),
233 "Network only needed when the model is not cached.".into(),
234 ];
235 caps
236}
237
238pub fn openrouter_stt_capabilities(model: &str, path: OpenRouterSttPath) -> ProviderCapabilities {
240 match path {
241 OpenRouterSttPath::Transcriptions => {
242 let mut caps =
243 ProviderCapabilities::with_core("openrouter", model, CapabilityOperation::Stt);
244 caps.stt_backend = Some(SttBackendClass::Asr);
245 caps.timestamps_reliable = true;
246 caps.languages = vec!["auto".into()];
247 caps.max_duration_secs = Some(3600.0);
248 caps.max_upload_bytes = Some(24 * 1024 * 1024);
249 caps.supports_cancellation = false;
250 caps.requires_network = true;
251 caps.local_only_ok = false;
252 caps.output_formats = vec!["txt".into(), "srt".into(), "json".into()];
253 caps.accepted_audio_formats = vec![
254 "wav".into(),
255 "mp3".into(),
256 "flac".into(),
257 "m4a".into(),
258 "ogg".into(),
259 ];
260 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
261 caps.notes = vec!["Dedicated /audio/transcriptions ASR path.".into()];
262 caps
263 }
264 OpenRouterSttPath::Chat => {
265 let mut caps =
266 ProviderCapabilities::with_core("openrouter", model, CapabilityOperation::Stt);
267 caps.stt_backend = Some(SttBackendClass::LlmAssisted);
268 caps.timestamps_reliable = false;
269 caps.languages = vec!["auto".into()];
270 caps.max_duration_secs = Some(3600.0);
271 caps.max_upload_bytes = Some(24 * 1024 * 1024);
272 caps.supports_cancellation = false;
273 caps.requires_network = true;
274 caps.local_only_ok = false;
275 caps.output_formats = vec!["txt".into(), "json".into()];
276 caps.accepted_audio_formats = vec![
277 "wav".into(),
278 "mp3".into(),
279 "flac".into(),
280 "m4a".into(),
281 "ogg".into(),
282 ];
283 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
284 caps.notes = vec![
285 "LLM-assisted multimodal chat path; timestamps are unreliable.".into(),
286 "Do not use SRT when timestamps_reliable is false.".into(),
287 ];
288 caps
289 }
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum OpenRouterSttPath {
296 Transcriptions,
297 Chat,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub struct OpenRouterSttRecord {
305 pub model_id: &'static str,
307 pub path: OpenRouterSttPath,
308 pub backend: SttBackendClass,
309 pub timestamps_reliable: bool,
310}
311
312pub static OPENROUTER_STT_REGISTRY: &[OpenRouterSttRecord] = &[
317 OpenRouterSttRecord {
318 model_id: "openai/whisper-1",
319 path: OpenRouterSttPath::Transcriptions,
320 backend: SttBackendClass::Asr,
321 timestamps_reliable: true,
322 },
323 OpenRouterSttRecord {
324 model_id: "openai/whisper-large-v3",
325 path: OpenRouterSttPath::Transcriptions,
326 backend: SttBackendClass::Asr,
327 timestamps_reliable: true,
328 },
329 OpenRouterSttRecord {
330 model_id: "openai/whisper-large-v3-turbo",
331 path: OpenRouterSttPath::Transcriptions,
332 backend: SttBackendClass::Asr,
333 timestamps_reliable: true,
334 },
335 OpenRouterSttRecord {
336 model_id: "openai/gpt-4o-transcribe",
337 path: OpenRouterSttPath::Transcriptions,
338 backend: SttBackendClass::Asr,
339 timestamps_reliable: true,
340 },
341 OpenRouterSttRecord {
342 model_id: "openai/gpt-4o-mini-transcribe",
343 path: OpenRouterSttPath::Transcriptions,
344 backend: SttBackendClass::Asr,
345 timestamps_reliable: true,
346 },
347 OpenRouterSttRecord {
348 model_id: "google/gemini-2.5-flash",
349 path: OpenRouterSttPath::Chat,
350 backend: SttBackendClass::LlmAssisted,
351 timestamps_reliable: false,
352 },
353 OpenRouterSttRecord {
354 model_id: "google/gemini-2.5-flash-lite",
355 path: OpenRouterSttPath::Chat,
356 backend: SttBackendClass::LlmAssisted,
357 timestamps_reliable: false,
358 },
359 OpenRouterSttRecord {
360 model_id: "google/gemini-2.5-pro",
361 path: OpenRouterSttPath::Chat,
362 backend: SttBackendClass::LlmAssisted,
363 timestamps_reliable: false,
364 },
365 OpenRouterSttRecord {
366 model_id: "google/gemini-2.0-flash",
367 path: OpenRouterSttPath::Chat,
368 backend: SttBackendClass::LlmAssisted,
369 timestamps_reliable: false,
370 },
371 OpenRouterSttRecord {
372 model_id: "openai/gpt-4o",
373 path: OpenRouterSttPath::Chat,
374 backend: SttBackendClass::LlmAssisted,
375 timestamps_reliable: false,
376 },
377 OpenRouterSttRecord {
378 model_id: "openai/gpt-4o-mini",
379 path: OpenRouterSttPath::Chat,
380 backend: SttBackendClass::LlmAssisted,
381 timestamps_reliable: false,
382 },
383 OpenRouterSttRecord {
384 model_id: "openai/gpt-4o-audio-preview",
385 path: OpenRouterSttPath::Chat,
386 backend: SttBackendClass::LlmAssisted,
387 timestamps_reliable: false,
388 },
389 OpenRouterSttRecord {
390 model_id: "openai/gpt-audio-mini",
391 path: OpenRouterSttPath::Chat,
392 backend: SttBackendClass::LlmAssisted,
393 timestamps_reliable: false,
394 },
395 OpenRouterSttRecord {
396 model_id: "mistralai/voxtral-small-24b-2507",
397 path: OpenRouterSttPath::Chat,
398 backend: SttBackendClass::LlmAssisted,
399 timestamps_reliable: false,
400 },
401];
402
403pub fn lookup_openrouter_stt(model: &str) -> Option<&'static OpenRouterSttRecord> {
405 let m = model.trim().to_ascii_lowercase();
406 if m.is_empty() {
407 return None;
408 }
409 OPENROUTER_STT_REGISTRY
410 .iter()
411 .find(|r| r.model_id == m.as_str())
412}
413
414pub fn resolve_openrouter_stt_path(
416 mode: OpenRouterSttMode,
417 model: &str,
418) -> Result<OpenRouterSttPath> {
419 match mode {
420 OpenRouterSttMode::Chat => Ok(OpenRouterSttPath::Chat),
421 OpenRouterSttMode::Transcriptions => Ok(OpenRouterSttPath::Transcriptions),
422 OpenRouterSttMode::Auto => match lookup_openrouter_stt(model) {
423 Some(rec) => Ok(rec.path),
424 None => Err(UnsupportedCapability {
425 provider: "openrouter".into(),
426 model: model.trim().into(),
427 reason: "auto routing has no reviewed capability record for this model".into(),
428 hint: "set openrouter_stt_mode=chat or transcriptions explicitly, or use a \
429 registered model id (see aurum capabilities / OPENROUTER_STT_REGISTRY)"
430 .into(),
431 }
432 .into()),
433 },
434 }
435}
436
437pub fn rules_cleanup_capabilities(language: &str) -> ProviderCapabilities {
439 let lang = language.trim().to_ascii_lowercase();
440 let english = lang.is_empty()
441 || lang == "auto"
442 || lang == "en"
443 || lang.starts_with("en-")
444 || lang == "eng";
445 let mut caps =
446 ProviderCapabilities::with_core("rules", "builtin", CapabilityOperation::Cleanup);
447 caps.languages = if english {
448 vec!["en".into(), "auto".into()]
449 } else {
450 vec![lang]
451 };
452 caps.max_text_chars = Some(500_000);
453 caps.supports_cancellation = true;
454 caps.requires_network = false;
455 caps.local_only_ok = true;
456 caps.output_formats = vec!["txt".into(), "json".into()];
457 caps.descriptor_freshness = DescriptorFreshness::Static;
458 caps.notes = if english {
459 vec!["English filler/contraction heuristics apply for clean/professional.".into()]
460 } else {
461 vec!["Non-English: only whitespace-safe normalization; no English filler deletion.".into()]
462 };
463 caps
464}
465
466pub fn local_tts_capabilities(model: &str) -> ProviderCapabilities {
468 let mut caps = ProviderCapabilities::with_core("local", model, CapabilityOperation::Tts);
469 caps.languages = vec!["en".into()];
470 caps.max_text_chars = Some(5_000);
471 caps.supports_cancellation = true;
472 caps.requires_network = false;
473 caps.local_only_ok = true;
474 caps.output_formats = vec!["wav".into(), "json".into()];
475 caps.voice_model = Some(VoiceModel::FixedAliases);
476 caps.supports_voice_ids = false;
477 caps.output_audio_formats = vec!["wav".into(), "pcm_f32le".into()];
478 caps.direct_pcm = true;
479 caps.sample_rates_hz = vec![24_000];
480 caps.supports_speaking_rate = true;
481 caps.speaking_rate_min = Some(0.5);
482 caps.speaking_rate_max = Some(2.0);
483 caps.streaming_advertised = false;
484 caps.streaming_implemented_by_aurum = false;
485 caps.descriptor_freshness = DescriptorFreshness::Static;
486 caps.notes = vec!["English KittenTTS ONNX path only.".into()];
487 caps
488}
489
490pub fn openai_stt_capabilities(model: &str) -> Result<ProviderCapabilities> {
492 use crate::providers::openai_stt::lookup_openai_stt;
493 let rec = lookup_openai_stt(model).ok_or_else(|| UnsupportedCapability {
494 provider: "openai".into(),
495 model: model.into(),
496 reason: "model is not in the reviewed OpenAI STT registry".into(),
497 hint: "use whisper-1, gpt-4o-mini-transcribe, or gpt-4o-transcribe".into(),
498 })?;
499 let mut caps = ProviderCapabilities::with_core("openai", rec.model, CapabilityOperation::Stt);
500 caps.stt_backend = Some(SttBackendClass::Asr);
501 caps.timestamps_reliable = rec.timestamps_supported;
502 caps.languages = vec!["auto".into(), "en".into()];
503 caps.max_upload_bytes = Some(rec.max_upload_bytes as u64);
504 caps.supports_cancellation = true;
505 caps.requires_network = true;
506 caps.local_only_ok = false;
507 caps.output_formats = vec!["txt".into(), "json".into(), "srt".into()];
508 caps.accepted_audio_formats = vec!["wav".into(), "mp3".into(), "m4a".into()];
509 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
510 caps.notes = vec![
511 "OpenAI first-party multipart /audio/transcriptions.".into(),
512 "Audio leaves the machine only when provider=openai is selected.".into(),
513 if rec.timestamps_supported {
514 "verbose_json segment timestamps available when requested.".into()
515 } else {
516 "JSON text only; SRT/timestamps not reliable for this model.".into()
517 },
518 ];
519 Ok(caps)
520}
521
522pub fn openai_tts_capabilities(model: &str) -> Result<ProviderCapabilities> {
524 #[cfg(feature = "tts")]
525 {
526 use crate::providers::openai_tts::lookup_openai_tts;
527 let rec = lookup_openai_tts(model).ok_or_else(|| UnsupportedCapability {
528 provider: "openai".into(),
529 model: model.into(),
530 reason: "model is not in the reviewed OpenAI TTS registry".into(),
531 hint: "use tts-1, tts-1-hd, or gpt-4o-mini-tts".into(),
532 })?;
533 let mut caps =
534 ProviderCapabilities::with_core("openai", rec.model, CapabilityOperation::Tts);
535 caps.languages = vec!["en".into(), "auto".into()];
536 caps.max_text_chars = Some(rec.max_text_chars);
537 caps.supports_cancellation = true;
538 caps.requires_network = true;
539 caps.local_only_ok = false;
540 caps.output_formats = vec!["wav".into(), "json".into()];
541 caps.voice_model = Some(VoiceModel::ProviderVoiceIds);
542 caps.supports_voice_ids = true;
543 caps.output_audio_formats = vec!["pcm_s16le".into(), "mp3".into()];
544 caps.direct_pcm = true;
545 caps.sample_rates_hz = vec![rec.default_sample_rate_hz];
546 caps.supports_speaking_rate = true;
547 caps.speaking_rate_min = Some(rec.rate_min);
548 caps.speaking_rate_max = Some(rec.rate_max);
549 caps.streaming_advertised = false;
550 caps.streaming_implemented_by_aurum = false;
551 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
552 caps.notes = vec![
553 "OpenAI first-party /audio/speech.".into(),
554 "Text is transmitted to OpenAI when provider=openai.".into(),
555 format!("Reviewed voices: {}.", rec.voices.join(", ")),
556 ];
557 Ok(caps)
558 }
559 #[cfg(not(feature = "tts"))]
560 {
561 let _ = model;
562 Err(UserError::Other {
563 message: "TTS support is not compiled into this build (feature `tts`)".into(),
564 }
565 .into())
566 }
567}
568
569pub fn xai_stt_capabilities(model: &str) -> Result<ProviderCapabilities> {
571 use crate::providers::xai_stt::lookup_xai_stt;
572 let rec = lookup_xai_stt(model).ok_or_else(|| UnsupportedCapability {
573 provider: "xai".into(),
574 model: model.into(),
575 reason: "model is not in the reviewed xAI STT registry".into(),
576 hint: "use xai-stt (official REST POST /v1/stt; not grok-asr*)".into(),
577 })?;
578 let mut caps = ProviderCapabilities::with_core("xai", rec.model, CapabilityOperation::Stt);
579 caps.stt_backend = Some(SttBackendClass::Asr);
580 caps.timestamps_reliable = rec.timestamps_supported;
581 caps.languages = vec!["auto".into(), "en".into()];
582 caps.max_upload_bytes = Some(rec.max_upload_bytes as u64);
583 caps.supports_cancellation = true;
584 caps.requires_network = true;
585 caps.local_only_ok = false;
586 caps.output_formats = vec!["txt".into(), "json".into(), "srt".into()];
587 caps.accepted_audio_formats = vec!["wav".into(), "mp3".into(), "m4a".into()];
588 caps.streaming_advertised = false;
589 caps.streaming_implemented_by_aurum = false;
590 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
591 caps.notes = vec![
592 "xAI official REST multipart POST /v1/stt (not OpenAI /audio/transcriptions).".into(),
593 "Product id xai-stt: API has no per-request model field.".into(),
594 "Realtime/WebSocket speech is not implemented; fails closed.".into(),
595 "Experimental: mocks only until protected credentialed smoke.".into(),
596 "Audio leaves the machine only when provider=xai (or alias grok) is selected.".into(),
597 ];
598 Ok(caps)
599}
600
601pub fn xai_tts_capabilities(model: &str) -> Result<ProviderCapabilities> {
603 #[cfg(feature = "tts")]
604 {
605 use crate::providers::xai_tts::lookup_xai_tts;
606 let rec = lookup_xai_tts(model).ok_or_else(|| UnsupportedCapability {
607 provider: "xai".into(),
608 model: model.into(),
609 reason: "model is not in the reviewed xAI TTS registry".into(),
610 hint: "use xai-tts with voice_id eve|ara|leo|rex|sal (not OpenAI alloy)".into(),
611 })?;
612 let mut caps = ProviderCapabilities::with_core("xai", rec.model, CapabilityOperation::Tts);
613 caps.languages = vec!["en".into(), "auto".into()];
614 caps.max_text_chars = Some(rec.max_text_chars);
615 caps.supports_cancellation = true;
616 caps.requires_network = true;
617 caps.local_only_ok = false;
618 caps.output_formats = vec!["wav".into(), "json".into()];
619 caps.voice_model = Some(VoiceModel::ProviderVoiceIds);
620 caps.supports_voice_ids = true;
621 caps.output_audio_formats = vec!["pcm_s16le".into()];
622 caps.direct_pcm = true;
623 caps.sample_rates_hz = vec![rec.default_sample_rate_hz];
624 caps.supports_speaking_rate = true;
625 caps.speaking_rate_min = Some(rec.rate_min);
626 caps.speaking_rate_max = Some(rec.rate_max);
627 caps.streaming_advertised = false;
628 caps.streaming_implemented_by_aurum = false;
629 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
630 caps.notes = vec![
631 "xAI official REST POST /v1/tts (not OpenAI /audio/speech).".into(),
632 "Product id xai-tts: API has no model field; built-in voices only.".into(),
633 "Streaming/realtime voice is not implemented.".into(),
634 "Experimental: mocks only until protected credentialed smoke.".into(),
635 format!("Reviewed voices: {}.", rec.voices.join(", ")),
636 ];
637 Ok(caps)
638 }
639 #[cfg(not(feature = "tts"))]
640 {
641 let _ = model;
642 Err(UserError::Other {
643 message: "TTS support is not compiled into this build (feature `tts`)".into(),
644 }
645 .into())
646 }
647}
648
649pub fn elevenlabs_tts_capabilities(model: &str) -> Result<ProviderCapabilities> {
651 #[cfg(feature = "tts")]
652 {
653 use crate::providers::elevenlabs_tts::lookup_elevenlabs_tts;
654 let rec = lookup_elevenlabs_tts(model).ok_or_else(|| UnsupportedCapability {
655 provider: "elevenlabs".into(),
656 model: model.into(),
657 reason: "model is not in the reviewed ElevenLabs TTS registry".into(),
658 hint: "use eleven_multilingual_v2, eleven_turbo_v2_5, or eleven_flash_v2_5".into(),
659 })?;
660 let mut caps =
661 ProviderCapabilities::with_core("elevenlabs", rec.model, CapabilityOperation::Tts);
662 caps.languages = vec!["auto".into(), "en".into()];
663 caps.max_text_chars = Some(rec.max_text_chars);
664 caps.supports_cancellation = true;
665 caps.requires_network = true;
666 caps.local_only_ok = false;
667 caps.output_formats = vec!["wav".into(), "json".into()];
668 caps.voice_model = Some(VoiceModel::ProviderVoiceIds);
669 caps.supports_voice_ids = true;
670 caps.output_audio_formats = vec!["pcm_s16le".into(), "mp3".into()];
671 caps.direct_pcm = true;
672 caps.sample_rates_hz = vec![rec.default_sample_rate_hz];
673 caps.supports_speaking_rate = true;
674 caps.speaking_rate_min = Some(rec.rate_min);
675 caps.speaking_rate_max = Some(rec.rate_max);
676 caps.streaming_advertised = false;
677 caps.streaming_implemented_by_aurum = false;
678 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
679 caps.notes = vec![
680 "ElevenLabs POST /v1/text-to-speech/{voice_id} with pcm_24000.".into(),
681 "Requires explicit ElevenLabs voice_id (never remapped from local aliases).".into(),
682 "Text is transmitted to ElevenLabs when provider=elevenlabs; retention is account-dependent."
683 .into(),
684 ];
685 Ok(caps)
686 }
687 #[cfg(not(feature = "tts"))]
688 {
689 let _ = model;
690 Err(UserError::Other {
691 message: "TTS support is not compiled into this build (feature `tts`)".into(),
692 }
693 .into())
694 }
695}
696
697pub fn openrouter_tts_capabilities(model: &str) -> Result<ProviderCapabilities> {
699 #[cfg(feature = "tts")]
700 {
701 use crate::providers::openrouter_tts::lookup_openrouter_tts;
702 let rec = lookup_openrouter_tts(model).ok_or_else(|| UnsupportedCapability {
703 provider: "openrouter".into(),
704 model: model.into(),
705 reason: "model is not in the reviewed OpenRouter TTS registry".into(),
706 hint: "use a reviewed OpenRouter TTS model (see docs/guide/providers.md)".into(),
707 })?;
708 let mut caps =
709 ProviderCapabilities::with_core("openrouter", rec.model, CapabilityOperation::Tts);
710 caps.languages = vec!["en".into(), "auto".into()];
711 caps.max_text_chars = Some(rec.max_text_chars);
712 caps.supports_cancellation = true;
713 caps.requires_network = true;
714 caps.local_only_ok = false;
715 caps.output_formats = vec!["wav".into(), "json".into()];
716 caps.voice_model = Some(VoiceModel::ProviderVoiceIds);
717 caps.supports_voice_ids = true;
718 caps.output_audio_formats = vec!["pcm_s16le".into(), "mp3".into()];
719 caps.direct_pcm = true;
720 caps.sample_rates_hz = vec![rec.default_sample_rate_hz];
721 caps.supports_speaking_rate = true;
722 caps.speaking_rate_min = Some(rec.rate_min);
723 caps.speaking_rate_max = Some(rec.rate_max);
724 caps.streaming_advertised = false;
725 caps.streaming_implemented_by_aurum = false;
726 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
727 caps.notes = vec![
728 "OpenRouter dedicated /audio/speech endpoint (OpenAI-compatible).".into(),
729 "Text is transmitted to OpenRouter / upstream; network privacy applies.".into(),
730 format!("Reviewed voices: {}.", rec.voices.join(", ")),
731 ];
732 Ok(caps)
733 }
734 #[cfg(not(feature = "tts"))]
735 {
736 let _ = model;
737 Err(UserError::Other {
738 message: "TTS support is not compiled into this build (feature `tts`)".into(),
739 }
740 .into())
741 }
742}
743
744pub fn preflight_stt(
746 provider: &str,
747 model: &str,
748 want_srt: bool,
749 local_only: bool,
750 stt_mode: OpenRouterSttMode,
751) -> Result<ProviderCapabilities> {
752 match provider {
753 "local" => Ok(local_whisper_capabilities(model)),
754 "openrouter" => {
755 if local_only {
756 return Err(UnsupportedCapability {
757 provider: provider.into(),
758 model: model.into(),
759 reason: "OpenRouter requires network access".into(),
760 hint: "unset local_only or use provider=local with a cached model".into(),
761 }
762 .into());
763 }
764 let path = resolve_openrouter_stt_path(stt_mode, model)?;
765 let mut caps = openrouter_stt_capabilities(model, path);
766 if let Some(rec) = lookup_openrouter_stt(model) {
767 caps.stt_backend = Some(rec.backend);
768 caps.timestamps_reliable = rec.timestamps_reliable;
769 caps.descriptor_freshness = DescriptorFreshness::Reviewed;
770 if !caps.notes.iter().any(|n| n.contains("registry")) {
771 caps.notes.push(format!(
772 "Routed via reviewed capability registry → {}.",
773 match rec.path {
774 OpenRouterSttPath::Transcriptions => "transcriptions",
775 OpenRouterSttPath::Chat => "chat",
776 }
777 ));
778 }
779 }
780 if want_srt && !caps.timestamps_reliable {
781 return Err(UnsupportedCapability {
782 provider: provider.into(),
783 model: model.into(),
784 reason: "SRT requires reliable timestamps; this model path is LLM-assisted"
785 .into(),
786 hint: "use openrouter_stt_mode=transcriptions with a dedicated ASR model, or output txt/json"
787 .into(),
788 }
789 .into());
790 }
791 Ok(caps)
792 }
793 "openai" => {
794 if local_only {
795 return Err(UnsupportedCapability {
796 provider: provider.into(),
797 model: model.into(),
798 reason: "OpenAI requires network access".into(),
799 hint: "unset local_only or use provider=local with a cached model".into(),
800 }
801 .into());
802 }
803 let caps = openai_stt_capabilities(model)?;
804 if want_srt && !caps.timestamps_reliable {
805 return Err(UnsupportedCapability {
806 provider: provider.into(),
807 model: model.into(),
808 reason: "SRT requires reliable timestamps; this OpenAI model is text-only JSON"
809 .into(),
810 hint: "use whisper-1 with timestamps, or output txt/json".into(),
811 }
812 .into());
813 }
814 Ok(caps)
815 }
816 "xai" => {
817 if local_only {
818 return Err(UnsupportedCapability {
819 provider: provider.into(),
820 model: model.into(),
821 reason: "xAI requires network access".into(),
822 hint: "unset local_only or use provider=local with a cached model".into(),
823 }
824 .into());
825 }
826 let caps = xai_stt_capabilities(model)?;
827 if want_srt && !caps.timestamps_reliable {
828 return Err(UnsupportedCapability {
829 provider: provider.into(),
830 model: model.into(),
831 reason: "SRT requires reliable timestamps; this xAI model is text-only JSON"
832 .into(),
833 hint: "use xai-stt with timestamps (word timings), or output txt/json".into(),
834 }
835 .into());
836 }
837 Ok(caps)
838 }
839 other => Err(UserError::InvalidProvider {
840 provider: other.into(),
841 }
842 .into()),
843 }
844}
845
846pub fn preflight_stt_for(
848 provider: &ProviderId,
849 model: &str,
850 want_srt: bool,
851 local_only: bool,
852 stt_mode: OpenRouterSttMode,
853) -> Result<ProviderCapabilities> {
854 preflight_stt(provider.as_str(), model, want_srt, local_only, stt_mode)
855}
856
857pub fn preflight_tts(language: &str, local_only: bool) -> Result<ProviderCapabilities> {
859 preflight_tts_for(&ProviderId::local(), language, local_only)
860}
861
862pub fn preflight_tts_for(
864 provider: &ProviderId,
865 language: &str,
866 local_only: bool,
867) -> Result<ProviderCapabilities> {
868 match provider.as_str() {
869 "local" => {
870 let caps = local_tts_capabilities("kitten-nano-int8");
871 if local_only && (caps.requires_network || !caps.local_only_ok) {
872 return Err(UnsupportedCapability {
873 provider: provider.as_str().into(),
874 model: caps.model.clone(),
875 reason: "TTS provider requires network under local_only".into(),
876 hint: "use a local TTS model or unset local_only".into(),
877 }
878 .into());
879 }
880 let lang = language.trim().to_ascii_lowercase();
881 if !(lang.is_empty() || lang == "en" || lang.starts_with("en-")) {
882 return Err(UnsupportedCapability {
883 provider: "local".into(),
884 model: caps.model.clone(),
885 reason: format!("TTS language '{language}' is not supported"),
886 hint: "use language=en for KittenTTS".into(),
887 }
888 .into());
889 }
890 Ok(caps)
891 }
892 "openrouter" => {
893 #[cfg(feature = "tts")]
894 {
895 if local_only {
896 return Err(UnsupportedCapability {
897 provider: provider.as_str().into(),
898 model: "*".into(),
899 reason: "OpenRouter TTS requires network access".into(),
900 hint: "unset local_only or use provider=local".into(),
901 }
902 .into());
903 }
904 openrouter_tts_capabilities(
906 crate::providers::openrouter_tts::DEFAULT_OPENROUTER_TTS_MODEL,
907 )
908 }
909 #[cfg(not(feature = "tts"))]
910 {
911 let _ = (provider, language, local_only);
912 Err(UserError::Other {
913 message: "TTS support is not compiled into this build (feature `tts`)".into(),
914 }
915 .into())
916 }
917 }
918 "openai" => {
919 #[cfg(feature = "tts")]
920 {
921 if local_only {
922 return Err(UnsupportedCapability {
923 provider: provider.as_str().into(),
924 model: "*".into(),
925 reason: "OpenAI TTS requires network access".into(),
926 hint: "unset local_only or use provider=local".into(),
927 }
928 .into());
929 }
930 openai_tts_capabilities(crate::providers::openai_tts::DEFAULT_OPENAI_TTS_MODEL)
931 }
932 #[cfg(not(feature = "tts"))]
933 {
934 let _ = (provider, language, local_only);
935 Err(UserError::Other {
936 message: "TTS support is not compiled into this build (feature `tts`)".into(),
937 }
938 .into())
939 }
940 }
941 "elevenlabs" => {
942 #[cfg(feature = "tts")]
943 {
944 if local_only {
945 return Err(UnsupportedCapability {
946 provider: provider.as_str().into(),
947 model: "*".into(),
948 reason: "ElevenLabs TTS requires network access".into(),
949 hint: "unset local_only or use provider=local".into(),
950 }
951 .into());
952 }
953 elevenlabs_tts_capabilities(
954 crate::providers::elevenlabs_tts::DEFAULT_ELEVENLABS_TTS_MODEL,
955 )
956 }
957 #[cfg(not(feature = "tts"))]
958 {
959 let _ = (provider, language, local_only);
960 Err(UserError::Other {
961 message: "TTS support is not compiled into this build (feature `tts`)".into(),
962 }
963 .into())
964 }
965 }
966 "xai" => {
967 #[cfg(feature = "tts")]
968 {
969 if local_only {
970 return Err(UnsupportedCapability {
971 provider: provider.as_str().into(),
972 model: "*".into(),
973 reason: "xAI TTS requires network access".into(),
974 hint: "unset local_only or use provider=local".into(),
975 }
976 .into());
977 }
978 xai_tts_capabilities(crate::providers::xai_tts::DEFAULT_XAI_TTS_MODEL)
979 }
980 #[cfg(not(feature = "tts"))]
981 {
982 let _ = (provider, language, local_only);
983 Err(UserError::Other {
984 message: "TTS support is not compiled into this build (feature `tts`)".into(),
985 }
986 .into())
987 }
988 }
989 other => Err(UserError::InvalidProvider {
990 provider: other.into(),
991 }
992 .into()),
993 }
994}
995
996pub fn preflight_cleanup(
997 provider: &str,
998 language: &str,
999 style: &str,
1000) -> Result<ProviderCapabilities> {
1001 match provider {
1002 "rules" | "local" => {
1003 let caps = rules_cleanup_capabilities(language);
1004 let _style = style.trim().to_ascii_lowercase();
1005 let _lang = language.trim().to_ascii_lowercase();
1006 Ok(caps)
1007 }
1008 "openrouter" => {
1009 let mut caps =
1010 ProviderCapabilities::with_core("openrouter", "chat", CapabilityOperation::Cleanup);
1011 caps.languages = vec!["auto".into()];
1012 caps.max_text_chars = Some(100_000);
1013 caps.supports_cancellation = false;
1014 caps.requires_network = true;
1015 caps.local_only_ok = false;
1016 caps.output_formats = vec!["txt".into(), "json".into()];
1017 caps.descriptor_freshness = DescriptorFreshness::Static;
1018 caps.notes = vec!["LLM rewrite; not deterministic.".into()];
1019 Ok(caps)
1020 }
1021 other => Err(UserError::Other {
1022 message: format!("unknown cleanup provider '{other}'"),
1023 }
1024 .into()),
1025 }
1026}
1027
1028pub fn preflight_cleanup_for(
1030 provider: &ProviderId,
1031 language: &str,
1032 style: &str,
1033) -> Result<ProviderCapabilities> {
1034 preflight_cleanup(provider.as_str(), language, style)
1035}
1036
1037pub fn apply_stt_request_gates(
1039 caps: &ProviderCapabilities,
1040 want_srt: bool,
1041 local_only: bool,
1042) -> Result<()> {
1043 if local_only && (caps.requires_network || !caps.local_only_ok) {
1044 return Err(UnsupportedCapability {
1045 provider: caps.provider.clone(),
1046 model: caps.model.clone(),
1047 reason: "provider requires network access under local_only".into(),
1048 hint: "unset local_only or use a local-only provider with a cached model".into(),
1049 }
1050 .into());
1051 }
1052 if want_srt && !caps.timestamps_reliable {
1053 return Err(UnsupportedCapability {
1054 provider: caps.provider.clone(),
1055 model: caps.model.clone(),
1056 reason: "SRT requires reliable timestamps".into(),
1057 hint: "use an ASR model with timestamps_reliable=true, or output txt/json".into(),
1058 }
1059 .into());
1060 }
1061 Ok(())
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067
1068 #[test]
1069 fn srt_blocked_for_llm_chat() {
1070 let err = preflight_stt(
1071 "openrouter",
1072 "google/gemini-2.5-flash",
1073 true,
1074 false,
1075 OpenRouterSttMode::Auto,
1076 )
1077 .unwrap_err();
1078 assert!(err.to_string().contains("SRT") || err.to_string().contains("timestamp"));
1079 }
1080
1081 #[test]
1082 fn whisper_auto_routes_transcriptions() {
1083 assert_eq!(
1084 resolve_openrouter_stt_path(OpenRouterSttMode::Auto, "openai/whisper-large-v3")
1085 .unwrap(),
1086 OpenRouterSttPath::Transcriptions
1087 );
1088 }
1089
1090 #[test]
1091 fn gemini_auto_routes_chat() {
1092 assert_eq!(
1093 resolve_openrouter_stt_path(OpenRouterSttMode::Auto, "google/gemini-2.5-flash")
1094 .unwrap(),
1095 OpenRouterSttPath::Chat
1096 );
1097 }
1098
1099 #[test]
1100 fn auto_unknown_model_fails_closed() {
1101 let err =
1102 resolve_openrouter_stt_path(OpenRouterSttMode::Auto, "acme/totally-unknown-asr-v99")
1103 .unwrap_err();
1104 let s = err.to_string();
1105 assert!(
1106 s.contains("reviewed capability") || s.contains("unsupported"),
1107 "unexpected error: {s}"
1108 );
1109 assert!(lookup_openrouter_stt("acme/totally-unknown-asr-v99").is_none());
1110 }
1111
1112 #[test]
1113 fn auto_does_not_guess_whisper_substring() {
1114 let err = resolve_openrouter_stt_path(
1115 OpenRouterSttMode::Auto,
1116 "vendor/whisper-clone-experimental",
1117 )
1118 .unwrap_err();
1119 assert!(err.to_string().contains("reviewed") || err.to_string().contains("unsupported"));
1120 }
1121
1122 #[test]
1123 fn explicit_mode_accepts_unregistered() {
1124 assert_eq!(
1125 resolve_openrouter_stt_path(OpenRouterSttMode::Transcriptions, "vendor/custom-asr")
1126 .unwrap(),
1127 OpenRouterSttPath::Transcriptions
1128 );
1129 assert_eq!(
1130 resolve_openrouter_stt_path(OpenRouterSttMode::Chat, "vendor/custom-llm").unwrap(),
1131 OpenRouterSttPath::Chat
1132 );
1133 }
1134
1135 #[test]
1136 fn registry_records_are_unique_lowercase() {
1137 let mut seen = std::collections::HashSet::new();
1138 for rec in OPENROUTER_STT_REGISTRY {
1139 assert_eq!(rec.model_id, rec.model_id.to_ascii_lowercase());
1140 assert!(
1141 seen.insert(rec.model_id),
1142 "duplicate registry model_id: {}",
1143 rec.model_id
1144 );
1145 assert_eq!(
1146 rec.timestamps_reliable,
1147 matches!(rec.path, OpenRouterSttPath::Transcriptions)
1148 );
1149 assert_eq!(
1150 rec.backend,
1151 match rec.path {
1152 OpenRouterSttPath::Transcriptions => SttBackendClass::Asr,
1153 OpenRouterSttPath::Chat => SttBackendClass::LlmAssisted,
1154 }
1155 );
1156 }
1157 }
1158
1159 #[test]
1160 fn offline_openrouter_fails() {
1161 let err = preflight_stt(
1162 "openrouter",
1163 "openai/whisper-large-v3",
1164 false,
1165 true,
1166 OpenRouterSttMode::Transcriptions,
1167 )
1168 .unwrap_err();
1169 assert!(err.to_string().contains("network") || err.to_string().contains("OpenRouter"));
1170 }
1171
1172 #[test]
1173 fn tts_rejects_french() {
1174 assert!(preflight_tts("fr", true).is_err());
1175 assert!(preflight_tts("en", true).is_ok());
1176 }
1177
1178 #[test]
1179 fn preflight_accepts_provider_id() {
1180 let local = ProviderId::local();
1181 let caps =
1182 preflight_stt_for(&local, "tiny-q5_1", false, true, OpenRouterSttMode::Auto).unwrap();
1183 assert_eq!(caps.provider, "local");
1184 assert!(caps.direct_pcm);
1185
1186 let or = ProviderId::openrouter();
1187 assert!(preflight_stt_for(
1188 &or,
1189 "openai/whisper-large-v3",
1190 false,
1191 true,
1192 OpenRouterSttMode::Transcriptions,
1193 )
1194 .is_err());
1195 }
1196
1197 #[test]
1198 fn capability_json_no_secrets_and_optional_fields_roundtrip() {
1199 let caps = local_whisper_capabilities("base");
1200 let s = serde_json::to_string(&caps).unwrap();
1201 assert!(!s.contains("sk-"));
1202 assert!(s.contains("schema_version"));
1203 let back: ProviderCapabilities = serde_json::from_str(&s).unwrap();
1204 assert_eq!(back.schema_version, CAPABILITY_SCHEMA_VERSION);
1205 assert_eq!(back.sample_rates_hz, vec![16_000]);
1206
1207 let legacy = r#"{
1208 "schema_version": 1,
1209 "provider": "local",
1210 "model": "base",
1211 "operation": "stt",
1212 "timestamps_reliable": true,
1213 "languages": ["en"],
1214 "max_duration_secs": null,
1215 "max_upload_bytes": null,
1216 "max_text_chars": null,
1217 "supports_cancellation": true,
1218 "requires_network": false,
1219 "local_only_ok": true,
1220 "output_formats": ["txt"],
1221 "notes": []
1222 }"#;
1223 let legacy_caps: ProviderCapabilities = serde_json::from_str(legacy).unwrap();
1224 assert!(!legacy_caps.supports_voice_ids);
1225 assert!(legacy_caps.accepted_audio_formats.is_empty());
1226 assert_eq!(
1227 legacy_caps.descriptor_freshness,
1228 DescriptorFreshness::Static
1229 );
1230 }
1231
1232 #[test]
1233 fn local_tts_declares_rate_and_pcm() {
1234 let caps = local_tts_capabilities("kitten-nano-int8");
1235 assert!(caps.supports_speaking_rate);
1236 assert_eq!(caps.speaking_rate_min, Some(0.5));
1237 assert_eq!(caps.speaking_rate_max, Some(2.0));
1238 assert!(caps.direct_pcm);
1239 assert_eq!(caps.voice_model, Some(VoiceModel::FixedAliases));
1240 assert!(!caps.streaming_implemented_by_aurum);
1241 }
1242}