Skip to main content

aurum_core/
capabilities.rs

1//! Provider/model capability contracts and preflight routing (JOE-1613 / JOE-1829 / JOE-1936).
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//!
9//! # Ownership (JOE-1936)
10//!
11//! Factories on [`crate::provider_platform::ProviderRegistry`] are the preferred
12//! lookup path via [`crate::provider_platform::capabilities_for`]. Free functions
13//! below remain the canonical **built-in** descriptors that factories call, so
14//! request-path preflight and discovery stay consistent without duplicating
15//! tables in the CLI.
16
17use crate::error::{Result, UserError};
18use crate::provider_platform::ProviderId;
19use crate::providers::OpenRouterSttMode;
20use serde::{Deserialize, Serialize};
21
22/// Schema version for capability JSON.
23///
24/// v1 remains the wire version: remote TTS/STT fields are **optional** with
25/// defaults so older readers ignore unknowns and older writers remain valid.
26pub const CAPABILITY_SCHEMA_VERSION: u32 = 1;
27
28/// Backend class for STT honesty.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum SttBackendClass {
32    Asr,
33    LlmAssisted,
34}
35
36/// How fresh / trusted a capability descriptor is.
37///
38/// Remote refresh is an explicit network action (not done by default). Labels
39/// keep discovery honest about whether values are compile-time static or
40/// human-reviewed catalogue entries.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum DescriptorFreshness {
44    /// Compile-time / code-owned declaration (default for free functions).
45    #[default]
46    Static,
47    /// Human-reviewed catalogue entry (e.g. OpenRouter auto-route table).
48    Reviewed,
49}
50
51/// Voice selection model advertised by a TTS (or multimodal) provider.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum VoiceModel {
55    /// Fixed set of named aliases (local Kitten voices, etc.).
56    FixedAliases,
57    /// Provider-assigned opaque voice identifiers.
58    ProviderVoiceIds,
59    /// No selectable voice (single-speaker or STT/cleanup).
60    None,
61}
62
63/// Declared capabilities for a provider/model combination.
64#[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    /// STT backend class when applicable.
71    #[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    /// High-level product output containers (txt/srt/json/wav), not raw codecs.
82    pub output_formats: Vec<String>,
83    pub notes: Vec<String>,
84
85    // ── Optional remote / audio-semantic extensions (JOE-1936, schema v1) ──
86    /// How voices are selected when `operation` is TTS (or multimodal).
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub voice_model: Option<VoiceModel>,
89    /// Whether the provider accepts opaque provider-native voice ids.
90    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
91    pub supports_voice_ids: bool,
92    /// Accepted input audio containers/encodings for STT (e.g. `wav`, `mp3`, `pcm_s16le`).
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub accepted_audio_formats: Vec<String>,
95    /// Output audio containers/encodings for TTS (e.g. `wav`, `mp3`, `pcm_f32le`).
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub output_audio_formats: Vec<String>,
98    /// Native PCM samples available without a container (library embed path).
99    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
100    pub direct_pcm: bool,
101    /// Sample rates the provider advertises (Hz). Empty = unspecified / native-only.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub sample_rates_hz: Vec<u32>,
104    /// Inclusive speaking-rate minimum when rate is supported.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub speaking_rate_min: Option<f32>,
107    /// Inclusive speaking-rate maximum when rate is supported.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub speaking_rate_max: Option<f32>,
110    /// Whether speaking-rate is an accepted request parameter.
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub supports_speaking_rate: bool,
113    /// Provider (or wire protocol) claims streaming is available.
114    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
115    pub streaming_advertised: bool,
116    /// Aurum actually implements streaming for this vertical today.
117    ///
118    /// Deliberately separate from [`Self::streaming_advertised`]: advertising
119    /// alone must not imply product support.
120    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
121    pub streaming_implemented_by_aurum: bool,
122    /// Freshness / trust label for this descriptor.
123    #[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    /// Extension fields left at honest defaults (empty / false / static).
133    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/// Why a request was rejected at preflight.
179#[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
209/// Local Whisper STT capabilities.
210pub 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; // download optional when cached
218    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
238/// OpenRouter STT capabilities after path resolution.
239pub 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/// Resolved OpenRouter STT route (capability-facing name for SttPath).
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum OpenRouterSttPath {
296    Transcriptions,
297    Chat,
298}
299
300/// Reviewed static capability record for an OpenRouter STT model id (JOE-1829).
301///
302/// `auto` routing consults this table only — never model-name substring guessing.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub struct OpenRouterSttRecord {
305    /// Canonical OpenRouter model slug (lowercase).
306    pub model_id: &'static str,
307    pub path: OpenRouterSttPath,
308    pub backend: SttBackendClass,
309    pub timestamps_reliable: bool,
310}
311
312/// Authoritative registry of OpenRouter models Aurum will auto-route.
313///
314/// Explicit `chat` / `transcriptions` modes still accept any model id; `auto`
315/// requires a match here so unfamiliar names fail closed.
316pub 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
403/// Look up a reviewed OpenRouter STT capability record (exact id, case-insensitive).
404pub 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
414/// Route OpenRouter STT from explicit mode + **reviewed** capability registry.
415pub 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
437/// Rules cleanup capabilities (language-aware).
438pub 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
466/// Local Kitten TTS capabilities.
467pub 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
490/// First-party OpenAI STT capabilities (JOE-1940). Fail closed for unknown models.
491pub 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
522/// First-party OpenAI TTS capabilities (JOE-1940).
523pub 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
569/// xAI REST STT capabilities (JOE-1942).
570pub 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
601/// xAI REST TTS capabilities (JOE-1942).
602pub 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
649/// ElevenLabs TTS capabilities (JOE-1941). Fail closed for unknown models.
650pub 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
697/// OpenRouter remote TTS capabilities (JOE-1939). Fail closed when model is unknown.
698pub 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
744/// Preflight STT: reject offline OpenRouter, unreliable SRT, unknown providers.
745pub 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
846/// [`preflight_stt`] with a validated [`ProviderId`].
847pub 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
857/// Preflight local TTS language support (legacy string API; assumes `local`).
858pub fn preflight_tts(language: &str, local_only: bool) -> Result<ProviderCapabilities> {
859    preflight_tts_for(&ProviderId::local(), language, local_only)
860}
861
862/// Preflight TTS for a validated provider id.
863pub 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                // Model-specific checks run at synthesize; gate network here.
905                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
1028/// [`preflight_cleanup`] with a validated [`ProviderId`].
1029pub 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
1037/// Apply common request gates against already-resolved capabilities (fail closed).
1038pub 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}