1use crate::error::{Result, UserError};
10use crate::providers::OpenRouterSttMode;
11use serde::{Deserialize, Serialize};
12
13pub const CAPABILITY_SCHEMA_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum SttBackendClass {
20 Asr,
21 LlmAssisted,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct ProviderCapabilities {
27 pub schema_version: u32,
28 pub provider: String,
29 pub model: String,
30 pub operation: CapabilityOperation,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub stt_backend: Option<SttBackendClass>,
34 pub timestamps_reliable: bool,
35 pub languages: Vec<String>,
36 pub max_duration_secs: Option<f64>,
37 pub max_upload_bytes: Option<u64>,
38 pub max_text_chars: Option<usize>,
39 pub supports_cancellation: bool,
40 pub requires_network: bool,
41 pub local_only_ok: bool,
42 pub output_formats: Vec<String>,
43 pub notes: Vec<String>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum CapabilityOperation {
49 Stt,
50 Tts,
51 Cleanup,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct UnsupportedCapability {
57 pub provider: String,
58 pub model: String,
59 pub reason: String,
60 pub hint: String,
61}
62
63impl std::fmt::Display for UnsupportedCapability {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 write!(
66 f,
67 "unsupported capability for {}/{}: {}\n Hint: {}",
68 self.provider, self.model, self.reason, self.hint
69 )
70 }
71}
72
73impl From<UnsupportedCapability> for crate::error::TranscriptionError {
74 fn from(u: UnsupportedCapability) -> Self {
75 UserError::UnsupportedCapability {
76 provider: u.provider,
77 model: u.model,
78 reason: u.reason,
79 hint: u.hint,
80 }
81 .into()
82 }
83}
84
85pub fn local_whisper_capabilities(model: &str) -> ProviderCapabilities {
87 ProviderCapabilities {
88 schema_version: CAPABILITY_SCHEMA_VERSION,
89 provider: "local".into(),
90 model: model.into(),
91 operation: CapabilityOperation::Stt,
92 stt_backend: Some(SttBackendClass::Asr),
93 timestamps_reliable: true,
94 languages: vec!["auto".into(), "en".into(), "multilingual".into()],
95 max_duration_secs: Some(2.5 * 3600.0),
96 max_upload_bytes: None,
97 max_text_chars: None,
98 supports_cancellation: true,
99 requires_network: false, local_only_ok: true,
101 output_formats: vec!["txt".into(), "srt".into(), "json".into()],
102 notes: vec![
103 "Timestamps are engine-derived ASR timings.".into(),
104 "Network only needed when the model is not cached.".into(),
105 ],
106 }
107}
108
109pub fn openrouter_stt_capabilities(model: &str, path: OpenRouterSttPath) -> ProviderCapabilities {
111 match path {
112 OpenRouterSttPath::Transcriptions => ProviderCapabilities {
113 schema_version: CAPABILITY_SCHEMA_VERSION,
114 provider: "openrouter".into(),
115 model: model.into(),
116 operation: CapabilityOperation::Stt,
117 stt_backend: Some(SttBackendClass::Asr),
118 timestamps_reliable: true,
119 languages: vec!["auto".into()],
120 max_duration_secs: Some(3600.0),
121 max_upload_bytes: Some(24 * 1024 * 1024),
122 max_text_chars: None,
123 supports_cancellation: false,
124 requires_network: true,
125 local_only_ok: false,
126 output_formats: vec!["txt".into(), "srt".into(), "json".into()],
127 notes: vec!["Dedicated /audio/transcriptions ASR path.".into()],
128 },
129 OpenRouterSttPath::Chat => ProviderCapabilities {
130 schema_version: CAPABILITY_SCHEMA_VERSION,
131 provider: "openrouter".into(),
132 model: model.into(),
133 operation: CapabilityOperation::Stt,
134 stt_backend: Some(SttBackendClass::LlmAssisted),
135 timestamps_reliable: false,
136 languages: vec!["auto".into()],
137 max_duration_secs: Some(3600.0),
138 max_upload_bytes: Some(24 * 1024 * 1024),
139 max_text_chars: None,
140 supports_cancellation: false,
141 requires_network: true,
142 local_only_ok: false,
143 output_formats: vec!["txt".into(), "json".into()],
144 notes: vec![
145 "LLM-assisted multimodal chat path; timestamps are unreliable.".into(),
146 "Do not use SRT when timestamps_reliable is false.".into(),
147 ],
148 },
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum OpenRouterSttPath {
155 Transcriptions,
156 Chat,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct OpenRouterSttRecord {
164 pub model_id: &'static str,
166 pub path: OpenRouterSttPath,
167 pub backend: SttBackendClass,
168 pub timestamps_reliable: bool,
169}
170
171pub static OPENROUTER_STT_REGISTRY: &[OpenRouterSttRecord] = &[
176 OpenRouterSttRecord {
178 model_id: "openai/whisper-1",
179 path: OpenRouterSttPath::Transcriptions,
180 backend: SttBackendClass::Asr,
181 timestamps_reliable: true,
182 },
183 OpenRouterSttRecord {
184 model_id: "openai/whisper-large-v3",
185 path: OpenRouterSttPath::Transcriptions,
186 backend: SttBackendClass::Asr,
187 timestamps_reliable: true,
188 },
189 OpenRouterSttRecord {
190 model_id: "openai/whisper-large-v3-turbo",
191 path: OpenRouterSttPath::Transcriptions,
192 backend: SttBackendClass::Asr,
193 timestamps_reliable: true,
194 },
195 OpenRouterSttRecord {
196 model_id: "openai/gpt-4o-transcribe",
197 path: OpenRouterSttPath::Transcriptions,
198 backend: SttBackendClass::Asr,
199 timestamps_reliable: true,
200 },
201 OpenRouterSttRecord {
202 model_id: "openai/gpt-4o-mini-transcribe",
203 path: OpenRouterSttPath::Transcriptions,
204 backend: SttBackendClass::Asr,
205 timestamps_reliable: true,
206 },
207 OpenRouterSttRecord {
209 model_id: "google/gemini-2.5-flash",
210 path: OpenRouterSttPath::Chat,
211 backend: SttBackendClass::LlmAssisted,
212 timestamps_reliable: false,
213 },
214 OpenRouterSttRecord {
215 model_id: "google/gemini-2.5-flash-lite",
216 path: OpenRouterSttPath::Chat,
217 backend: SttBackendClass::LlmAssisted,
218 timestamps_reliable: false,
219 },
220 OpenRouterSttRecord {
221 model_id: "google/gemini-2.5-pro",
222 path: OpenRouterSttPath::Chat,
223 backend: SttBackendClass::LlmAssisted,
224 timestamps_reliable: false,
225 },
226 OpenRouterSttRecord {
227 model_id: "google/gemini-2.0-flash",
228 path: OpenRouterSttPath::Chat,
229 backend: SttBackendClass::LlmAssisted,
230 timestamps_reliable: false,
231 },
232 OpenRouterSttRecord {
233 model_id: "openai/gpt-4o",
234 path: OpenRouterSttPath::Chat,
235 backend: SttBackendClass::LlmAssisted,
236 timestamps_reliable: false,
237 },
238 OpenRouterSttRecord {
239 model_id: "openai/gpt-4o-mini",
240 path: OpenRouterSttPath::Chat,
241 backend: SttBackendClass::LlmAssisted,
242 timestamps_reliable: false,
243 },
244 OpenRouterSttRecord {
245 model_id: "openai/gpt-4o-audio-preview",
246 path: OpenRouterSttPath::Chat,
247 backend: SttBackendClass::LlmAssisted,
248 timestamps_reliable: false,
249 },
250 OpenRouterSttRecord {
251 model_id: "openai/gpt-audio-mini",
252 path: OpenRouterSttPath::Chat,
253 backend: SttBackendClass::LlmAssisted,
254 timestamps_reliable: false,
255 },
256 OpenRouterSttRecord {
257 model_id: "mistralai/voxtral-small-24b-2507",
258 path: OpenRouterSttPath::Chat,
259 backend: SttBackendClass::LlmAssisted,
260 timestamps_reliable: false,
261 },
262];
263
264pub fn lookup_openrouter_stt(model: &str) -> Option<&'static OpenRouterSttRecord> {
266 let m = model.trim().to_ascii_lowercase();
267 if m.is_empty() {
268 return None;
269 }
270 OPENROUTER_STT_REGISTRY
271 .iter()
272 .find(|r| r.model_id == m.as_str())
273}
274
275pub fn resolve_openrouter_stt_path(
281 mode: OpenRouterSttMode,
282 model: &str,
283) -> Result<OpenRouterSttPath> {
284 match mode {
285 OpenRouterSttMode::Chat => Ok(OpenRouterSttPath::Chat),
286 OpenRouterSttMode::Transcriptions => Ok(OpenRouterSttPath::Transcriptions),
287 OpenRouterSttMode::Auto => match lookup_openrouter_stt(model) {
288 Some(rec) => Ok(rec.path),
289 None => Err(UnsupportedCapability {
290 provider: "openrouter".into(),
291 model: model.trim().into(),
292 reason: "auto routing has no reviewed capability record for this model".into(),
293 hint: "set openrouter_stt_mode=chat or transcriptions explicitly, or use a \
294 registered model id (see aurum capabilities / OPENROUTER_STT_REGISTRY)"
295 .into(),
296 }
297 .into()),
298 },
299 }
300}
301
302pub fn rules_cleanup_capabilities(language: &str) -> ProviderCapabilities {
304 let lang = language.trim().to_ascii_lowercase();
305 let english = lang.is_empty()
306 || lang == "auto"
307 || lang == "en"
308 || lang.starts_with("en-")
309 || lang == "eng";
310 ProviderCapabilities {
311 schema_version: CAPABILITY_SCHEMA_VERSION,
312 provider: "rules".into(),
313 model: "builtin".into(),
314 operation: CapabilityOperation::Cleanup,
315 stt_backend: None,
316 timestamps_reliable: false,
317 languages: if english {
318 vec!["en".into(), "auto".into()]
319 } else {
320 vec![lang]
321 },
322 max_duration_secs: None,
323 max_upload_bytes: None,
324 max_text_chars: Some(500_000),
325 supports_cancellation: true,
326 requires_network: false,
327 local_only_ok: true,
328 output_formats: vec!["txt".into(), "json".into()],
329 notes: if english {
330 vec!["English filler/contraction heuristics apply for clean/professional.".into()]
331 } else {
332 vec![
333 "Non-English: only whitespace-safe normalization; no English filler deletion."
334 .into(),
335 ]
336 },
337 }
338}
339
340pub fn local_tts_capabilities(model: &str) -> ProviderCapabilities {
342 ProviderCapabilities {
343 schema_version: CAPABILITY_SCHEMA_VERSION,
344 provider: "local".into(),
345 model: model.into(),
346 operation: CapabilityOperation::Tts,
347 stt_backend: None,
348 timestamps_reliable: false,
349 languages: vec!["en".into()],
350 max_duration_secs: None,
351 max_upload_bytes: None,
352 max_text_chars: Some(5_000),
353 supports_cancellation: true,
354 requires_network: false,
355 local_only_ok: true,
356 output_formats: vec!["wav".into(), "json".into()],
357 notes: vec!["English KittenTTS ONNX path only.".into()],
358 }
359}
360
361pub fn preflight_stt(
363 provider: &str,
364 model: &str,
365 want_srt: bool,
366 local_only: bool,
367 stt_mode: OpenRouterSttMode,
368) -> Result<ProviderCapabilities> {
369 match provider {
370 "local" => {
371 let caps = local_whisper_capabilities(model);
372 Ok(caps)
373 }
374 "openrouter" => {
375 if local_only {
376 return Err(UnsupportedCapability {
377 provider: provider.into(),
378 model: model.into(),
379 reason: "OpenRouter requires network access".into(),
380 hint: "unset local_only or use provider=local with a cached model".into(),
381 }
382 .into());
383 }
384 let path = resolve_openrouter_stt_path(stt_mode, model)?;
385 let mut caps = openrouter_stt_capabilities(model, path);
386 if let Some(rec) = lookup_openrouter_stt(model) {
388 caps.stt_backend = Some(rec.backend);
389 caps.timestamps_reliable = rec.timestamps_reliable;
390 if !caps.notes.iter().any(|n| n.contains("registry")) {
391 caps.notes.push(format!(
392 "Routed via reviewed capability registry → {}.",
393 match rec.path {
394 OpenRouterSttPath::Transcriptions => "transcriptions",
395 OpenRouterSttPath::Chat => "chat",
396 }
397 ));
398 }
399 }
400 if want_srt && !caps.timestamps_reliable {
401 return Err(UnsupportedCapability {
402 provider: provider.into(),
403 model: model.into(),
404 reason: "SRT requires reliable timestamps; this model path is LLM-assisted"
405 .into(),
406 hint: "use openrouter_stt_mode=transcriptions with a dedicated ASR model, or output txt/json"
407 .into(),
408 }
409 .into());
410 }
411 Ok(caps)
412 }
413 other => Err(UserError::InvalidProvider {
414 provider: other.into(),
415 }
416 .into()),
417 }
418}
419
420pub fn preflight_tts(language: &str, local_only: bool) -> Result<ProviderCapabilities> {
421 let caps = local_tts_capabilities("kitten-nano-int8");
422 let lang = language.trim().to_ascii_lowercase();
423 if !(lang.is_empty() || lang == "en" || lang.starts_with("en-")) {
424 return Err(UnsupportedCapability {
425 provider: "local".into(),
426 model: caps.model.clone(),
427 reason: format!("TTS language '{language}' is not supported"),
428 hint: "use language=en for KittenTTS".into(),
429 }
430 .into());
431 }
432 let _ = local_only;
433 Ok(caps)
434}
435
436pub fn preflight_cleanup(
437 provider: &str,
438 language: &str,
439 style: &str,
440) -> Result<ProviderCapabilities> {
441 match provider {
442 "rules" | "local" => {
443 let caps = rules_cleanup_capabilities(language);
444 let style = style.trim().to_ascii_lowercase();
445 let lang = language.trim().to_ascii_lowercase();
446 let english =
447 lang.is_empty() || lang == "auto" || lang == "en" || lang.starts_with("en-");
448 if !english
449 && matches!(
450 style.as_str(),
451 "clean" | "professional" | "bullets" | "summary"
452 )
453 {
454 }
456 Ok(caps)
457 }
458 "openrouter" => Ok(ProviderCapabilities {
459 schema_version: CAPABILITY_SCHEMA_VERSION,
460 provider: "openrouter".into(),
461 model: "chat".into(),
462 operation: CapabilityOperation::Cleanup,
463 stt_backend: None,
464 timestamps_reliable: false,
465 languages: vec!["auto".into()],
466 max_duration_secs: None,
467 max_upload_bytes: None,
468 max_text_chars: Some(100_000),
469 supports_cancellation: false,
470 requires_network: true,
471 local_only_ok: false,
472 output_formats: vec!["txt".into(), "json".into()],
473 notes: vec!["LLM rewrite; not deterministic.".into()],
474 }),
475 other => Err(UserError::Other {
476 message: format!("unknown cleanup provider '{other}'"),
477 }
478 .into()),
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 #[test]
487 fn srt_blocked_for_llm_chat() {
488 let err = preflight_stt(
489 "openrouter",
490 "google/gemini-2.5-flash",
491 true,
492 false,
493 OpenRouterSttMode::Auto,
494 )
495 .unwrap_err();
496 assert!(err.to_string().contains("SRT") || err.to_string().contains("timestamp"));
497 }
498
499 #[test]
500 fn whisper_auto_routes_transcriptions() {
501 assert_eq!(
502 resolve_openrouter_stt_path(OpenRouterSttMode::Auto, "openai/whisper-large-v3")
503 .unwrap(),
504 OpenRouterSttPath::Transcriptions
505 );
506 }
507
508 #[test]
509 fn gemini_auto_routes_chat() {
510 assert_eq!(
511 resolve_openrouter_stt_path(OpenRouterSttMode::Auto, "google/gemini-2.5-flash")
512 .unwrap(),
513 OpenRouterSttPath::Chat
514 );
515 }
516
517 #[test]
518 fn auto_unknown_model_fails_closed() {
519 let err =
520 resolve_openrouter_stt_path(OpenRouterSttMode::Auto, "acme/totally-unknown-asr-v99")
521 .unwrap_err();
522 let s = err.to_string();
523 assert!(
524 s.contains("reviewed capability") || s.contains("unsupported"),
525 "unexpected error: {s}"
526 );
527 assert!(lookup_openrouter_stt("acme/totally-unknown-asr-v99").is_none());
529 }
530
531 #[test]
532 fn auto_does_not_guess_whisper_substring() {
533 let err = resolve_openrouter_stt_path(
535 OpenRouterSttMode::Auto,
536 "vendor/whisper-clone-experimental",
537 )
538 .unwrap_err();
539 assert!(err.to_string().contains("reviewed") || err.to_string().contains("unsupported"));
540 }
541
542 #[test]
543 fn explicit_mode_accepts_unregistered() {
544 assert_eq!(
545 resolve_openrouter_stt_path(OpenRouterSttMode::Transcriptions, "vendor/custom-asr")
546 .unwrap(),
547 OpenRouterSttPath::Transcriptions
548 );
549 assert_eq!(
550 resolve_openrouter_stt_path(OpenRouterSttMode::Chat, "vendor/custom-llm").unwrap(),
551 OpenRouterSttPath::Chat
552 );
553 }
554
555 #[test]
556 fn registry_records_are_unique_lowercase() {
557 let mut seen = std::collections::HashSet::new();
558 for rec in OPENROUTER_STT_REGISTRY {
559 assert_eq!(rec.model_id, rec.model_id.to_ascii_lowercase());
560 assert!(
561 seen.insert(rec.model_id),
562 "duplicate registry model_id: {}",
563 rec.model_id
564 );
565 assert_eq!(
566 rec.timestamps_reliable,
567 matches!(rec.path, OpenRouterSttPath::Transcriptions)
568 );
569 assert_eq!(
570 rec.backend,
571 match rec.path {
572 OpenRouterSttPath::Transcriptions => SttBackendClass::Asr,
573 OpenRouterSttPath::Chat => SttBackendClass::LlmAssisted,
574 }
575 );
576 }
577 }
578
579 #[test]
580 fn offline_openrouter_fails() {
581 let err = preflight_stt(
582 "openrouter",
583 "openai/whisper-large-v3",
584 false,
585 true,
586 OpenRouterSttMode::Transcriptions,
587 )
588 .unwrap_err();
589 assert!(err.to_string().contains("network") || err.to_string().contains("OpenRouter"));
590 }
591
592 #[test]
593 fn tts_rejects_french() {
594 assert!(preflight_tts("fr", true).is_err());
595 assert!(preflight_tts("en", true).is_ok());
596 }
597
598 #[test]
599 fn capability_json_no_secrets() {
600 let caps = local_whisper_capabilities("base");
601 let s = serde_json::to_string(&caps).unwrap();
602 assert!(!s.contains("sk-"));
603 assert!(s.contains("schema_version"));
604 }
605}