Skip to main content

aurum_core/
capabilities.rs

1//! Provider/model capability contracts and preflight routing (JOE-1613 / JOE-1829).
2//!
3//! Capabilities are declared for built-ins and consulted before expensive work
4//! (decode, download, network). OpenRouter `auto` routing is **capability-
5//! authoritative**: only models in the reviewed static registry are auto-routed;
6//! unknown models fail closed and require an explicit `chat` or `transcriptions`
7//! mode — never silent model-name heuristics.
8
9use crate::error::{Result, UserError};
10use crate::providers::OpenRouterSttMode;
11use serde::{Deserialize, Serialize};
12
13/// Schema version for capability JSON.
14pub const CAPABILITY_SCHEMA_VERSION: u32 = 1;
15
16/// Backend class for STT honesty.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum SttBackendClass {
20    Asr,
21    LlmAssisted,
22}
23
24/// Declared capabilities for a provider/model combination.
25#[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    /// STT backend class when applicable.
32    #[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/// Why a request was rejected at preflight.
55#[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
85/// Local Whisper STT capabilities.
86pub 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, // download optional when cached
100        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
109/// OpenRouter STT capabilities after path resolution.
110pub 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/// Resolved OpenRouter STT route (capability-facing name for SttPath).
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum OpenRouterSttPath {
155    Transcriptions,
156    Chat,
157}
158
159/// Reviewed static capability record for an OpenRouter STT model id (JOE-1829).
160///
161/// `auto` routing consults this table only — never model-name substring guessing.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct OpenRouterSttRecord {
164    /// Canonical OpenRouter model slug (lowercase).
165    pub model_id: &'static str,
166    pub path: OpenRouterSttPath,
167    pub backend: SttBackendClass,
168    pub timestamps_reliable: bool,
169}
170
171/// Authoritative registry of OpenRouter models Aurum will auto-route.
172///
173/// Explicit `chat` / `transcriptions` modes still accept any model id; `auto`
174/// requires a match here so unfamiliar names fail closed.
175pub static OPENROUTER_STT_REGISTRY: &[OpenRouterSttRecord] = &[
176    // Dedicated /audio/transcriptions ASR
177    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    // Multimodal chat (LLM-assisted; timestamps unreliable)
208    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
264/// Look up a reviewed OpenRouter STT capability record (exact id, case-insensitive).
265pub 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
275/// Route OpenRouter STT from explicit mode + **reviewed** capability registry.
276///
277/// * `Chat` / `Transcriptions` — honour the override for any model id.
278/// * `Auto` — only models present in [`OPENROUTER_STT_REGISTRY`]; unknown models
279///   fail closed with [`UnsupportedCapability`] (no silent name guessing).
280pub 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
302/// Rules cleanup capabilities (language-aware).
303pub 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
340/// Local Kitten TTS capabilities.
341pub 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
361/// Preflight: reject offline OpenRouter, non-English TTS, etc.
362pub 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            // Prefer registry truth for backend/timestamps when the model is known.
387            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                // Allowed but degraded — still return caps with notes (honest).
455            }
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        // Must not silently claim transcriptions or chat via name guessing.
528        assert!(lookup_openrouter_stt("acme/totally-unknown-asr-v99").is_none());
529    }
530
531    #[test]
532    fn auto_does_not_guess_whisper_substring() {
533        // A model that merely contains "whisper" but is not registered must fail.
534        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}