Skip to main content

aurum_core/
capabilities.rs

1//! Provider/model capability contracts and preflight routing (JOE-1613).
2//!
3//! Capabilities are declared for built-ins and consulted before expensive work
4//! (decode, download, network). Routing uses this data plus explicit overrides
5//! rather than silent name guessing alone.
6
7use crate::error::{Result, UserError};
8use crate::providers::OpenRouterSttMode;
9use serde::{Deserialize, Serialize};
10
11/// Schema version for capability JSON.
12pub const CAPABILITY_SCHEMA_VERSION: u32 = 1;
13
14/// Backend class for STT honesty.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum SttBackendClass {
18    Asr,
19    LlmAssisted,
20}
21
22/// Declared capabilities for a provider/model combination.
23#[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    /// STT backend class when applicable.
30    #[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/// Why a request was rejected at preflight.
53#[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
83/// Local Whisper STT capabilities.
84pub 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, // download optional when cached
98        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
107/// OpenRouter STT capabilities after path resolution.
108pub 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/// Resolved OpenRouter STT route (capability-facing name for SttPath).
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum OpenRouterSttPath {
153    Transcriptions,
154    Chat,
155}
156
157/// Route OpenRouter STT from explicit mode + capability-oriented model metadata.
158///
159/// `Auto` uses documented model-id heuristics; unknown models fail closed to
160/// **Chat** with an explicit note rather than claiming dedicated ASR.
161pub 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
179/// Rules cleanup capabilities (language-aware).
180pub 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
217/// Local Kitten TTS capabilities.
218pub 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
238/// Preflight: reject offline OpenRouter, non-English TTS, etc.
239pub 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                // Allowed but degraded — still return caps with notes (honest).
318            }
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}