1use crate::error::{Result, UserError};
8use crate::providers::OpenRouterSttMode;
9use serde::{Deserialize, Serialize};
10
11pub const CAPABILITY_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum SttBackendClass {
18 Asr,
19 LlmAssisted,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct ProviderCapabilities {
25 pub schema_version: u32,
26 pub provider: String,
27 pub model: String,
28 pub operation: CapabilityOperation,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub stt_backend: Option<SttBackendClass>,
32 pub timestamps_reliable: bool,
33 pub languages: Vec<String>,
34 pub max_duration_secs: Option<f64>,
35 pub max_upload_bytes: Option<u64>,
36 pub max_text_chars: Option<usize>,
37 pub supports_cancellation: bool,
38 pub requires_network: bool,
39 pub local_only_ok: bool,
40 pub output_formats: Vec<String>,
41 pub notes: Vec<String>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum CapabilityOperation {
47 Stt,
48 Tts,
49 Cleanup,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct UnsupportedCapability {
55 pub provider: String,
56 pub model: String,
57 pub reason: String,
58 pub hint: String,
59}
60
61impl std::fmt::Display for UnsupportedCapability {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(
64 f,
65 "unsupported capability for {}/{}: {}\n Hint: {}",
66 self.provider, self.model, self.reason, self.hint
67 )
68 }
69}
70
71impl From<UnsupportedCapability> for crate::error::TranscriptionError {
72 fn from(u: UnsupportedCapability) -> Self {
73 UserError::UnsupportedCapability {
74 provider: u.provider,
75 model: u.model,
76 reason: u.reason,
77 hint: u.hint,
78 }
79 .into()
80 }
81}
82
83pub fn local_whisper_capabilities(model: &str) -> ProviderCapabilities {
85 ProviderCapabilities {
86 schema_version: CAPABILITY_SCHEMA_VERSION,
87 provider: "local".into(),
88 model: model.into(),
89 operation: CapabilityOperation::Stt,
90 stt_backend: Some(SttBackendClass::Asr),
91 timestamps_reliable: true,
92 languages: vec!["auto".into(), "en".into(), "multilingual".into()],
93 max_duration_secs: Some(2.5 * 3600.0),
94 max_upload_bytes: None,
95 max_text_chars: None,
96 supports_cancellation: true,
97 requires_network: false, local_only_ok: true,
99 output_formats: vec!["txt".into(), "srt".into(), "json".into()],
100 notes: vec![
101 "Timestamps are engine-derived ASR timings.".into(),
102 "Network only needed when the model is not cached.".into(),
103 ],
104 }
105}
106
107pub fn openrouter_stt_capabilities(model: &str, path: OpenRouterSttPath) -> ProviderCapabilities {
109 match path {
110 OpenRouterSttPath::Transcriptions => ProviderCapabilities {
111 schema_version: CAPABILITY_SCHEMA_VERSION,
112 provider: "openrouter".into(),
113 model: model.into(),
114 operation: CapabilityOperation::Stt,
115 stt_backend: Some(SttBackendClass::Asr),
116 timestamps_reliable: true,
117 languages: vec!["auto".into()],
118 max_duration_secs: Some(3600.0),
119 max_upload_bytes: Some(24 * 1024 * 1024),
120 max_text_chars: None,
121 supports_cancellation: false,
122 requires_network: true,
123 local_only_ok: false,
124 output_formats: vec!["txt".into(), "srt".into(), "json".into()],
125 notes: vec!["Dedicated /audio/transcriptions ASR path.".into()],
126 },
127 OpenRouterSttPath::Chat => ProviderCapabilities {
128 schema_version: CAPABILITY_SCHEMA_VERSION,
129 provider: "openrouter".into(),
130 model: model.into(),
131 operation: CapabilityOperation::Stt,
132 stt_backend: Some(SttBackendClass::LlmAssisted),
133 timestamps_reliable: false,
134 languages: vec!["auto".into()],
135 max_duration_secs: Some(3600.0),
136 max_upload_bytes: Some(24 * 1024 * 1024),
137 max_text_chars: None,
138 supports_cancellation: false,
139 requires_network: true,
140 local_only_ok: false,
141 output_formats: vec!["txt".into(), "json".into()],
142 notes: vec![
143 "LLM-assisted multimodal chat path; timestamps are unreliable.".into(),
144 "Do not use SRT when timestamps_reliable is false.".into(),
145 ],
146 },
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum OpenRouterSttPath {
153 Transcriptions,
154 Chat,
155}
156
157pub fn resolve_openrouter_stt_path(mode: OpenRouterSttMode, model: &str) -> OpenRouterSttPath {
162 match mode {
163 OpenRouterSttMode::Chat => OpenRouterSttPath::Chat,
164 OpenRouterSttMode::Transcriptions => OpenRouterSttPath::Transcriptions,
165 OpenRouterSttMode::Auto => {
166 let m = model.to_ascii_lowercase();
167 if m.contains("whisper")
168 || m.contains("gpt-4o-transcribe")
169 || m.contains("gpt-4o-mini-transcribe")
170 {
171 OpenRouterSttPath::Transcriptions
172 } else {
173 OpenRouterSttPath::Chat
174 }
175 }
176 }
177}
178
179pub fn rules_cleanup_capabilities(language: &str) -> ProviderCapabilities {
181 let lang = language.trim().to_ascii_lowercase();
182 let english = lang.is_empty()
183 || lang == "auto"
184 || lang == "en"
185 || lang.starts_with("en-")
186 || lang == "eng";
187 ProviderCapabilities {
188 schema_version: CAPABILITY_SCHEMA_VERSION,
189 provider: "rules".into(),
190 model: "builtin".into(),
191 operation: CapabilityOperation::Cleanup,
192 stt_backend: None,
193 timestamps_reliable: false,
194 languages: if english {
195 vec!["en".into(), "auto".into()]
196 } else {
197 vec![lang]
198 },
199 max_duration_secs: None,
200 max_upload_bytes: None,
201 max_text_chars: Some(500_000),
202 supports_cancellation: true,
203 requires_network: false,
204 local_only_ok: true,
205 output_formats: vec!["txt".into(), "json".into()],
206 notes: if english {
207 vec!["English filler/contraction heuristics apply for clean/professional.".into()]
208 } else {
209 vec![
210 "Non-English: only whitespace-safe normalization; no English filler deletion."
211 .into(),
212 ]
213 },
214 }
215}
216
217pub fn local_tts_capabilities(model: &str) -> ProviderCapabilities {
219 ProviderCapabilities {
220 schema_version: CAPABILITY_SCHEMA_VERSION,
221 provider: "local".into(),
222 model: model.into(),
223 operation: CapabilityOperation::Tts,
224 stt_backend: None,
225 timestamps_reliable: false,
226 languages: vec!["en".into()],
227 max_duration_secs: None,
228 max_upload_bytes: None,
229 max_text_chars: Some(5_000),
230 supports_cancellation: true,
231 requires_network: false,
232 local_only_ok: true,
233 output_formats: vec!["wav".into(), "json".into()],
234 notes: vec!["English KittenTTS ONNX path only.".into()],
235 }
236}
237
238pub fn preflight_stt(
240 provider: &str,
241 model: &str,
242 want_srt: bool,
243 local_only: bool,
244 stt_mode: OpenRouterSttMode,
245) -> Result<ProviderCapabilities> {
246 match provider {
247 "local" => {
248 let caps = local_whisper_capabilities(model);
249 Ok(caps)
250 }
251 "openrouter" => {
252 if local_only {
253 return Err(UnsupportedCapability {
254 provider: provider.into(),
255 model: model.into(),
256 reason: "OpenRouter requires network access".into(),
257 hint: "unset local_only or use provider=local with a cached model".into(),
258 }
259 .into());
260 }
261 let path = resolve_openrouter_stt_path(stt_mode, model);
262 let caps = openrouter_stt_capabilities(model, path);
263 if want_srt && !caps.timestamps_reliable {
264 return Err(UnsupportedCapability {
265 provider: provider.into(),
266 model: model.into(),
267 reason: "SRT requires reliable timestamps; this model path is LLM-assisted"
268 .into(),
269 hint: "use openrouter_stt_mode=transcriptions with a dedicated ASR model, or output txt/json"
270 .into(),
271 }
272 .into());
273 }
274 Ok(caps)
275 }
276 other => Err(UserError::InvalidProvider {
277 provider: other.into(),
278 }
279 .into()),
280 }
281}
282
283pub fn preflight_tts(language: &str, local_only: bool) -> Result<ProviderCapabilities> {
284 let caps = local_tts_capabilities("kitten-nano-int8");
285 let lang = language.trim().to_ascii_lowercase();
286 if !(lang.is_empty() || lang == "en" || lang.starts_with("en-")) {
287 return Err(UnsupportedCapability {
288 provider: "local".into(),
289 model: caps.model.clone(),
290 reason: format!("TTS language '{language}' is not supported"),
291 hint: "use language=en for KittenTTS".into(),
292 }
293 .into());
294 }
295 let _ = local_only;
296 Ok(caps)
297}
298
299pub fn preflight_cleanup(
300 provider: &str,
301 language: &str,
302 style: &str,
303) -> Result<ProviderCapabilities> {
304 match provider {
305 "rules" | "local" => {
306 let caps = rules_cleanup_capabilities(language);
307 let style = style.trim().to_ascii_lowercase();
308 let lang = language.trim().to_ascii_lowercase();
309 let english =
310 lang.is_empty() || lang == "auto" || lang == "en" || lang.starts_with("en-");
311 if !english
312 && matches!(
313 style.as_str(),
314 "clean" | "professional" | "bullets" | "summary"
315 )
316 {
317 }
319 Ok(caps)
320 }
321 "openrouter" => Ok(ProviderCapabilities {
322 schema_version: CAPABILITY_SCHEMA_VERSION,
323 provider: "openrouter".into(),
324 model: "chat".into(),
325 operation: CapabilityOperation::Cleanup,
326 stt_backend: None,
327 timestamps_reliable: false,
328 languages: vec!["auto".into()],
329 max_duration_secs: None,
330 max_upload_bytes: None,
331 max_text_chars: Some(100_000),
332 supports_cancellation: false,
333 requires_network: true,
334 local_only_ok: false,
335 output_formats: vec!["txt".into(), "json".into()],
336 notes: vec!["LLM rewrite; not deterministic.".into()],
337 }),
338 other => Err(UserError::Other {
339 message: format!("unknown cleanup provider '{other}'"),
340 }
341 .into()),
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn srt_blocked_for_llm_chat() {
351 let err = preflight_stt(
352 "openrouter",
353 "google/gemini-2.5-flash",
354 true,
355 false,
356 OpenRouterSttMode::Auto,
357 )
358 .unwrap_err();
359 assert!(err.to_string().contains("SRT") || err.to_string().contains("timestamp"));
360 }
361
362 #[test]
363 fn whisper_auto_routes_transcriptions() {
364 assert_eq!(
365 resolve_openrouter_stt_path(OpenRouterSttMode::Auto, "openai/whisper-large-v3"),
366 OpenRouterSttPath::Transcriptions
367 );
368 }
369
370 #[test]
371 fn offline_openrouter_fails() {
372 let err = preflight_stt(
373 "openrouter",
374 "openai/whisper-large-v3",
375 false,
376 true,
377 OpenRouterSttMode::Transcriptions,
378 )
379 .unwrap_err();
380 assert!(err.to_string().contains("network") || err.to_string().contains("OpenRouter"));
381 }
382
383 #[test]
384 fn tts_rejects_french() {
385 assert!(preflight_tts("fr", true).is_err());
386 assert!(preflight_tts("en", true).is_ok());
387 }
388
389 #[test]
390 fn capability_json_no_secrets() {
391 let caps = local_whisper_capabilities("base");
392 let s = serde_json::to_string(&caps).unwrap();
393 assert!(!s.contains("sk-"));
394 assert!(s.contains("schema_version"));
395 }
396}