Skip to main content

aurum_core/providers/
openrouter.rs

1//! OpenRouter remote transcription provider.
2//!
3//! Supports two request paths (JOE-1586):
4//! - **Dedicated ASR** — multipart `POST /audio/transcriptions` for models that
5//!   expose a real transcription endpoint (e.g. OpenAI Whisper-class models).
6//! - **LLM-assisted** — multimodal `POST /chat/completions` with `input_audio`
7//!   (Gemini etc.). Timestamps are unreliable.
8//!
9//! Path selection: config/CLI mode, then model-name heuristics in `auto`.
10
11use super::{
12    BackendKind, Segment, TranscriptionOptions, TranscriptionProvider, TranscriptionResult,
13};
14use crate::audio::{self, AudioInput, DEFAULT_FFMPEG_TIMEOUT, DEFAULT_MAX_UPLOAD_BYTES};
15use crate::error::{ProviderError, Result, UserError};
16use crate::postprocess;
17use crate::remote::{
18    map_http_status, read_body_limited, validate_segments, validate_text_bounds,
19    HardenedHttpClient, RemoteBodyLimits, RemotePolicy, TranscriptLimits,
20};
21use async_trait::async_trait;
22use reqwest::multipart::{Form, Part};
23use serde::{Deserialize, Serialize};
24use serde_json::json;
25use std::path::PathBuf;
26
27const PROVIDER_NAME: &str = "openrouter";
28
29/// How to route OpenRouter STT requests (JOE-1586).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum OpenRouterSttMode {
32    /// Heuristic + capability: dedicated models → transcriptions, else chat.
33    #[default]
34    Auto,
35    /// Always multimodal chat completions (`LlmAssisted`).
36    Chat,
37    /// Always dedicated `/audio/transcriptions` (`Asr`).
38    Transcriptions,
39}
40
41impl OpenRouterSttMode {
42    pub fn parse(s: &str) -> Result<Self> {
43        match s.trim().to_ascii_lowercase().as_str() {
44            "auto" | "" => Ok(Self::Auto),
45            "chat" | "llm" | "completions" => Ok(Self::Chat),
46            "transcriptions" | "asr" | "dedicated" | "audio" => Ok(Self::Transcriptions),
47            other => Err(UserError::Other {
48                message: format!(
49                    "unknown openrouter STT mode '{other}'\n  \
50                     Hint: use one of: auto, chat, transcriptions"
51                ),
52            }
53            .into()),
54        }
55    }
56
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::Auto => "auto",
60            Self::Chat => "chat",
61            Self::Transcriptions => "transcriptions",
62        }
63    }
64}
65
66/// OpenRouter provider with dual STT paths.
67pub struct OpenRouterProvider {
68    api_key: String,
69    http: HardenedHttpClient,
70    max_upload_bytes: usize,
71    stt_mode: OpenRouterSttMode,
72}
73
74impl std::fmt::Debug for OpenRouterProvider {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("OpenRouterProvider")
77            .field("base_url", &self.http.base_url())
78            .field("api_key", &"***")
79            .field("max_upload_bytes", &self.max_upload_bytes)
80            .field("stt_mode", &self.stt_mode)
81            .finish()
82    }
83}
84
85impl OpenRouterProvider {
86    pub fn new(api_key: Option<String>, base_url: Option<String>) -> Result<Self> {
87        Self::with_policy(
88            api_key,
89            base_url,
90            RemotePolicy::default(),
91            OpenRouterSttMode::Auto,
92        )
93    }
94
95    pub fn with_policy(
96        api_key: Option<String>,
97        base_url: Option<String>,
98        mut policy: RemotePolicy,
99        stt_mode: OpenRouterSttMode,
100    ) -> Result<Self> {
101        let api_key = api_key
102            .map(|s| s.trim().to_string())
103            .filter(|s| !s.is_empty())
104            .ok_or(UserError::MissingApiKey)?;
105
106        // Wiremock / local tests use loopback HTTP.
107        if base_url
108            .as_deref()
109            .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"))
110        {
111            policy.allow_loopback_http = true;
112        }
113
114        let http = HardenedHttpClient::build(base_url.as_deref(), policy)?;
115
116        Ok(Self {
117            api_key,
118            http,
119            max_upload_bytes: DEFAULT_MAX_UPLOAD_BYTES,
120            stt_mode,
121        })
122    }
123
124    pub fn with_stt_mode(mut self, mode: OpenRouterSttMode) -> Self {
125        self.stt_mode = mode;
126        self
127    }
128
129    /// Resolve which path to use for `model` (capability-oriented routing, JOE-1613).
130    pub fn resolve_path(&self, model: &str) -> SttPath {
131        use crate::capabilities::{resolve_openrouter_stt_path, OpenRouterSttPath};
132        match resolve_openrouter_stt_path(self.stt_mode, model) {
133            OpenRouterSttPath::Chat => SttPath::Chat,
134            OpenRouterSttPath::Transcriptions => SttPath::Transcriptions,
135        }
136    }
137}
138
139/// Selected request path.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum SttPath {
142    Chat,
143    Transcriptions,
144}
145
146/// Heuristic for dedicated ASR model ids (not sole source of truth; config can override).
147pub fn looks_like_dedicated_asr(model: &str) -> bool {
148    let m = model.to_ascii_lowercase();
149    // OpenAI Whisper / GPT-4o-transcribe style ids and common OpenRouter slugs.
150    m.contains("whisper")
151        || m.contains("transcribe")
152        || m.contains("/audio-")
153        || m.ends_with("-asr")
154        || m.contains("speech-to-text")
155        || m.contains("nova-2")
156        || m.contains("nova-3")
157}
158
159#[async_trait]
160impl TranscriptionProvider for OpenRouterProvider {
161    fn name(&self) -> &'static str {
162        PROVIDER_NAME
163    }
164
165    fn backend_kind(&self) -> BackendKind {
166        // Default label; actual result uses path-specific backend_kind.
167        BackendKind::LlmAssisted
168    }
169
170    async fn transcribe(
171        &self,
172        input: &AudioInput,
173        options: &TranscriptionOptions,
174    ) -> Result<TranscriptionResult> {
175        let op = crate::runtime::OpContext::from_optional_cancel(options.cancel.clone());
176        op.check()?;
177        let gov = crate::runtime::ResourceGovernor::process_global();
178        let _permit = gov.acquire(crate::runtime::PermitKind::Remote, Some(&op))?;
179        let path = self.resolve_path(&options.model);
180        match path {
181            SttPath::Transcriptions => self.transcribe_dedicated(input, options).await,
182            SttPath::Chat => self.transcribe_chat(input, options).await,
183        }
184    }
185}
186
187impl OpenRouterProvider {
188    async fn transcribe_dedicated(
189        &self,
190        input: &AudioInput,
191        options: &TranscriptionOptions,
192    ) -> Result<TranscriptionResult> {
193        let pcm_bytes = input
194            .samples
195            .len()
196            .saturating_mul(std::mem::size_of::<f32>());
197        // Propagate cancel + absolute encode deadline (JOE-1648 third-pass).
198        let (upload_path, format) = audio::encode_for_upload_with_timeout(
199            &input.samples,
200            self.max_upload_bytes,
201            DEFAULT_FFMPEG_TIMEOUT,
202            options.cancel.clone(),
203        )
204        .await?;
205        if options.cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
206            let _ = std::fs::remove_file(&upload_path);
207            return Err(crate::error::ProviderError::Cancelled.into());
208        }
209        let cleanup = scopeguard_path(upload_path.clone());
210
211        let meta = tokio::fs::metadata(&upload_path)
212            .await
213            .map_err(|e| ProviderError::Other {
214                message: format!("stat upload artifact: {e}"),
215            })?;
216        let encoded_len = meta.len() as usize;
217        if encoded_len > self.max_upload_bytes {
218            return Err(UserError::AudioTooLarge {
219                decoded_bytes: encoded_len,
220                max_bytes: self.max_upload_bytes,
221            }
222            .into());
223        }
224
225        tracing::debug!(
226            pcm_bytes,
227            encoded_bytes = encoded_len,
228            format,
229            "openrouter dedicated upload artifact ready"
230        );
231
232        // Stream multipart file from disk — no full base64, no second full
233        // in-memory buffer for the dedicated path (JOE-1603).
234        let filename = format!("audio.{format}");
235        let mime = match format {
236            "mp3" => "audio/mpeg",
237            "wav" => "audio/wav",
238            _ => "application/octet-stream",
239        };
240        let part = Part::file(&upload_path)
241            .await
242            .map_err(|e| ProviderError::Other {
243                message: format!("multipart file part: {e}"),
244            })?
245            .file_name(filename)
246            .mime_str(mime)
247            .map_err(|e| ProviderError::Other {
248                message: format!("multipart mime: {e}"),
249            })?;
250
251        let mut form = Form::new()
252            .text("model", options.model.clone())
253            .part("file", part);
254        let lang = options.language.trim().to_ascii_lowercase();
255        if !lang.is_empty() && lang != "auto" {
256            form = form.text("language", lang.clone());
257        }
258        if options.timestamps {
259            form = form.text("response_format", "verbose_json");
260        } else {
261            form = form.text("response_format", "json");
262        }
263
264        tracing::debug!(
265            model = %options.model,
266            path = "audio/transcriptions",
267            "openrouter dedicated STT request"
268        );
269
270        let response = self
271            .http
272            .request(reqwest::Method::POST, "audio/transcriptions", &self.api_key)?
273            .multipart(form)
274            .send()
275            .await
276            .map_err(|e| ProviderError::Network {
277                provider: PROVIDER_NAME.into(),
278                reason: e.to_string(),
279            })?;
280
281        // Body no longer needs the on-disk artifact.
282        drop(cleanup);
283
284        let status = response.status();
285        let body = read_body_limited(response, PROVIDER_NAME, RemoteBodyLimits::stt()).await?;
286        let body_text = String::from_utf8_lossy(&body).into_owned();
287        map_http_status(PROVIDER_NAME, status, &body_text)?;
288
289        let (text, segments, timestamps_reliable) =
290            parse_transcriptions_body(&body_text, options.timestamps, input.duration_secs)?;
291        validate_text_bounds(&text, None, TranscriptLimits::default(), PROVIDER_NAME)?;
292        validate_segments(
293            &segments,
294            input.duration_secs,
295            TranscriptLimits::default(),
296            PROVIDER_NAME,
297        )?;
298
299        let mut result = TranscriptionResult::openrouter(
300            text,
301            segments,
302            if lang != "auto" && !lang.is_empty() {
303                Some(lang)
304            } else {
305                None
306            },
307            options.model.clone(),
308            input.duration_secs,
309            options.timestamps,
310        );
311        // Dedicated ASR path.
312        result.backend_kind = BackendKind::Asr;
313        result.timestamps_reliable = timestamps_reliable;
314        result.provider = PROVIDER_NAME.into();
315        Ok(postprocess::normalize_result(result))
316    }
317
318    async fn transcribe_chat(
319        &self,
320        input: &AudioInput,
321        options: &TranscriptionOptions,
322    ) -> Result<TranscriptionResult> {
323        let (upload_path, format) = audio::encode_for_upload_with_timeout(
324            &input.samples,
325            self.max_upload_bytes,
326            DEFAULT_FFMPEG_TIMEOUT,
327            options.cancel.clone(),
328        )
329        .await?;
330        if options.cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
331            let _ = std::fs::remove_file(&upload_path);
332            return Err(crate::error::ProviderError::Cancelled.into());
333        }
334        let cleanup = scopeguard_path(upload_path.clone());
335
336        let meta = tokio::fs::metadata(&upload_path)
337            .await
338            .map_err(|e| ProviderError::Other {
339                message: format!("stat upload artifact: {e}"),
340            })?;
341        let encoded_len = meta.len() as usize;
342        if encoded_len > self.max_upload_bytes {
343            return Err(UserError::AudioTooLarge {
344                decoded_bytes: encoded_len,
345                max_bytes: self.max_upload_bytes,
346            }
347            .into());
348        }
349
350        // Incremental base64 from a file reader — avoids holding raw file bytes
351        // and base64 simultaneously longer than necessary (JOE-1603).
352        let b64 = {
353            use base64::Engine;
354            use std::io::Read;
355            let mut file = std::fs::File::open(&upload_path).map_err(|e| ProviderError::Other {
356                message: format!("open upload for base64: {e}"),
357            })?;
358            let mut raw = Vec::with_capacity(encoded_len);
359            file.read_to_end(&mut raw)
360                .map_err(|e| ProviderError::Other {
361                    message: format!("read upload for base64: {e}"),
362                })?;
363            // Cap on wire bytes after base64 expansion (~4/3).
364            let b64_est = raw.len().saturating_mul(4).div_ceil(3);
365            if b64_est > self.max_upload_bytes.saturating_mul(2) {
366                return Err(UserError::AudioTooLarge {
367                    decoded_bytes: b64_est,
368                    max_bytes: self.max_upload_bytes.saturating_mul(2),
369                }
370                .into());
371            }
372            let encoded = base64::engine::general_purpose::STANDARD.encode(&raw);
373            drop(raw);
374            encoded
375        };
376        drop(cleanup);
377
378        let mut prompt =
379            String::from("Transcribe the audio verbatim. Reply with ONLY the transcript text");
380        if options.timestamps {
381            prompt.push_str(
382                ", as a JSON object with keys \"text\" (string) and \"segments\" \
383                 (array of {\"start\": number, \"end\": number, \"text\": string}) \
384                 where times are in seconds. Do not wrap in markdown. \
385                 If you cannot produce reliable timestamps, return text only as plain string.",
386            );
387        } else {
388            prompt.push_str(". Do not add commentary, labels, or markdown.");
389        }
390
391        let lang = options.language.trim().to_ascii_lowercase();
392        if !lang.is_empty() && lang != "auto" {
393            prompt.push_str(&format!(" The audio language is \"{lang}\"."));
394        }
395
396        let body = json!({
397            "model": options.model,
398            "messages": [{
399                "role": "user",
400                "content": [
401                    { "type": "text", "text": prompt },
402                    {
403                        "type": "input_audio",
404                        "input_audio": {
405                            "data": b64,
406                            "format": format
407                        }
408                    }
409                ]
410            }],
411            "temperature": 0,
412            "top_p": 1,
413        });
414
415        tracing::debug!(
416            model = %options.model,
417            path = "chat/completions",
418            "openrouter LLM-assisted STT request"
419        );
420
421        let response = self
422            .http
423            .request(reqwest::Method::POST, "chat/completions", &self.api_key)?
424            .header("Content-Type", "application/json")
425            .json(&body)
426            .send()
427            .await
428            .map_err(|e| ProviderError::Network {
429                provider: PROVIDER_NAME.into(),
430                reason: e.to_string(),
431            })?;
432
433        let status = response.status();
434        let body_bytes =
435            read_body_limited(response, PROVIDER_NAME, RemoteBodyLimits::chat()).await?;
436        let body_text = String::from_utf8_lossy(&body_bytes).into_owned();
437        map_http_status(PROVIDER_NAME, status, &body_text)?;
438
439        let parsed: ChatCompletionResponse = serde_json::from_str(&body_text).map_err(|e| {
440            ProviderError::InvalidProviderPayload {
441                provider: PROVIDER_NAME.into(),
442                reason: format!("invalid JSON: {e}"),
443            }
444        })?;
445
446        let content = parsed
447            .choices
448            .first()
449            .and_then(|c| c.message.content.as_deref())
450            .unwrap_or("")
451            .trim()
452            .to_string();
453
454        if content.is_empty() {
455            return Err(ProviderError::TranscriptionFailed {
456                reason: "OpenRouter returned an empty transcript".into(),
457            }
458            .into());
459        }
460
461        let (text, segments) =
462            parse_chat_content(&content, options.timestamps, input.duration_secs);
463        validate_text_bounds(&text, None, TranscriptLimits::default(), PROVIDER_NAME)?;
464        // LLM segments are not trusted for ordering hard-fail; soft-validate only when present.
465        if options.timestamps {
466            let _ = validate_segments(
467                &segments,
468                input.duration_secs,
469                TranscriptLimits::default(),
470                PROVIDER_NAME,
471            );
472        }
473
474        let result = TranscriptionResult::openrouter(
475            text,
476            segments,
477            if lang != "auto" && !lang.is_empty() {
478                Some(lang)
479            } else {
480                None
481            },
482            options.model.clone(),
483            input.duration_secs,
484            options.timestamps,
485        );
486        Ok(postprocess::normalize_result(result))
487    }
488}
489
490struct PathGuard(PathBuf);
491impl Drop for PathGuard {
492    fn drop(&mut self) {
493        let _ = std::fs::remove_file(&self.0);
494    }
495}
496fn scopeguard_path(path: PathBuf) -> PathGuard {
497    PathGuard(path)
498}
499
500#[derive(Debug, Deserialize)]
501struct ChatCompletionResponse {
502    choices: Vec<Choice>,
503}
504
505#[derive(Debug, Deserialize)]
506struct Choice {
507    message: Message,
508}
509
510#[derive(Debug, Deserialize)]
511struct Message {
512    content: Option<String>,
513}
514
515#[derive(Debug, Deserialize, Serialize)]
516struct TimestampPayload {
517    text: String,
518    #[serde(default)]
519    segments: Vec<Segment>,
520}
521
522#[derive(Debug, Deserialize)]
523struct TranscriptionsJson {
524    text: String,
525    #[serde(default)]
526    segments: Option<Vec<TranscriptionsSegment>>,
527}
528
529#[derive(Debug, Deserialize)]
530struct TranscriptionsSegment {
531    #[serde(default)]
532    start: f64,
533    #[serde(default)]
534    end: f64,
535    #[serde(default)]
536    text: String,
537}
538
539fn parse_transcriptions_body(
540    body: &str,
541    want_timestamps: bool,
542    duration: f64,
543) -> Result<(String, Vec<Segment>, bool)> {
544    // Plain text response_format=text
545    if !body.trim_start().starts_with('{') {
546        let text = body.trim().to_string();
547        if text.is_empty() {
548            return Err(ProviderError::TranscriptionFailed {
549                reason: "empty transcription response".into(),
550            }
551            .into());
552        }
553        return Ok((
554            text.clone(),
555            vec![Segment::from_parts_unchecked(0.0, duration, text)],
556            false,
557        ));
558    }
559
560    let parsed: TranscriptionsJson =
561        serde_json::from_str(body).map_err(|e| ProviderError::InvalidProviderPayload {
562            provider: PROVIDER_NAME.into(),
563            reason: format!("transcriptions JSON: {e}"),
564        })?;
565
566    let text = parsed.text.trim().to_string();
567    if text.is_empty() {
568        return Err(ProviderError::TranscriptionFailed {
569            reason: "empty transcription text".into(),
570        }
571        .into());
572    }
573
574    if want_timestamps {
575        if let Some(raw_segs) = parsed.segments {
576            let segments: Vec<Segment> = raw_segs
577                .into_iter()
578                .map(|s| Segment::from_parts_unchecked(s.start, s.end, s.text))
579                .collect();
580            // Dedicated verbose_json segments are treated as engine-derived.
581            return Ok((text, segments, true));
582        }
583    }
584
585    Ok((
586        text.clone(),
587        vec![Segment::from_parts_unchecked(0.0, duration, text)],
588        false,
589    ))
590}
591
592fn parse_chat_content(
593    content: &str,
594    want_timestamps: bool,
595    duration: f64,
596) -> (String, Vec<Segment>) {
597    if want_timestamps {
598        let cleaned = content
599            .trim()
600            .trim_start_matches("```json")
601            .trim_start_matches("```")
602            .trim_end_matches("```")
603            .trim();
604        if let Ok(payload) = serde_json::from_str::<TimestampPayload>(cleaned) {
605            return (payload.text, payload.segments);
606        }
607    }
608
609    let text = content.to_string();
610    let segments = vec![Segment::from_parts_unchecked(0.0, duration, text.clone())];
611    (text, segments)
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use std::sync::Arc;
618    use wiremock::matchers::{method, path};
619    use wiremock::{Mock, MockServer, ResponseTemplate};
620
621    #[test]
622    fn mode_parse() {
623        assert_eq!(
624            OpenRouterSttMode::parse("auto").unwrap(),
625            OpenRouterSttMode::Auto
626        );
627        assert_eq!(
628            OpenRouterSttMode::parse("transcriptions").unwrap(),
629            OpenRouterSttMode::Transcriptions
630        );
631        assert!(OpenRouterSttMode::parse("nope").is_err());
632    }
633
634    #[test]
635    fn dedicated_heuristics() {
636        assert!(looks_like_dedicated_asr("openai/whisper-1"));
637        assert!(looks_like_dedicated_asr("openai/gpt-4o-transcribe"));
638        assert!(!looks_like_dedicated_asr("google/gemini-2.5-flash"));
639    }
640
641    #[tokio::test]
642    async fn missing_key_fails_early() {
643        let err = OpenRouterProvider::new(None, None).unwrap_err();
644        assert!(matches!(
645            err,
646            crate::error::TranscriptionError::User(UserError::MissingApiKey)
647        ));
648    }
649
650    #[tokio::test]
651    async fn parses_successful_chat_response() {
652        let server = MockServer::start().await;
653        Mock::given(method("POST"))
654            .and(path("/chat/completions"))
655            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
656                "choices": [{
657                    "message": { "content": "Hello from the cloud." }
658                }]
659            })))
660            .mount(&server)
661            .await;
662
663        let provider = OpenRouterProvider::with_policy(
664            Some("test-key".into()),
665            Some(server.uri()),
666            RemotePolicy {
667                allow_loopback_http: true,
668                ..Default::default()
669            },
670            OpenRouterSttMode::Chat,
671        )
672        .unwrap();
673
674        let samples: Arc<[f32]> = vec![0.0f32; 1600].into();
675        let input = AudioInput {
676            source_path: PathBuf::from("silent.wav"),
677            samples,
678            sample_rate: 16_000,
679            duration_secs: 0.1,
680        };
681        let opts = TranscriptionOptions {
682            model: "google/gemini-2.5-flash".into(),
683            language: "en".into(),
684            timestamps: false,
685            cancel: None,
686        };
687        let result = provider.transcribe(&input, &opts).await.unwrap();
688        assert_eq!(result.text, "Hello from the cloud.");
689        assert_eq!(result.provider, "openrouter");
690        assert_eq!(result.backend_kind, BackendKind::LlmAssisted);
691        assert!(!result.timestamps_reliable);
692    }
693
694    #[tokio::test]
695    async fn dedicated_path_hits_transcriptions() {
696        let server = MockServer::start().await;
697        Mock::given(method("POST"))
698            .and(path("/audio/transcriptions"))
699            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
700                "text": "Dedicated ASR path works."
701            })))
702            .mount(&server)
703            .await;
704
705        let provider = OpenRouterProvider::with_policy(
706            Some("test-key".into()),
707            Some(server.uri()),
708            RemotePolicy {
709                allow_loopback_http: true,
710                ..Default::default()
711            },
712            OpenRouterSttMode::Transcriptions,
713        )
714        .unwrap();
715
716        let input = AudioInput {
717            source_path: PathBuf::from("x.wav"),
718            samples: vec![0.0; 1600].into(),
719            sample_rate: 16_000,
720            duration_secs: 0.1,
721        };
722        let opts = TranscriptionOptions {
723            model: "openai/whisper-1".into(),
724            language: "en".into(),
725            timestamps: false,
726            cancel: None,
727        };
728        let result = provider.transcribe(&input, &opts).await.unwrap();
729        assert_eq!(result.text, "Dedicated ASR path works.");
730        assert_eq!(result.backend_kind, BackendKind::Asr);
731    }
732
733    #[tokio::test]
734    async fn maps_rate_limit() {
735        let server = MockServer::start().await;
736        Mock::given(method("POST"))
737            .and(path("/chat/completions"))
738            .respond_with(ResponseTemplate::new(429).set_body_string("slow down"))
739            .mount(&server)
740            .await;
741
742        let provider = OpenRouterProvider::with_policy(
743            Some("test-key".into()),
744            Some(server.uri()),
745            RemotePolicy {
746                allow_loopback_http: true,
747                ..Default::default()
748            },
749            OpenRouterSttMode::Chat,
750        )
751        .unwrap();
752        let input = AudioInput {
753            source_path: PathBuf::from("x.wav"),
754            samples: vec![0.0; 1600].into(),
755            sample_rate: 16_000,
756            duration_secs: 0.1,
757        };
758        let opts = TranscriptionOptions {
759            model: "google/gemini-2.5-flash".into(),
760            language: "auto".into(),
761            timestamps: false,
762            cancel: None,
763        };
764        let err = provider.transcribe(&input, &opts).await.unwrap_err();
765        match err {
766            crate::error::TranscriptionError::Provider(ProviderError::RateLimited { .. }) => {}
767            other => panic!("expected rate limit, got {other}"),
768        }
769    }
770
771    #[test]
772    fn parse_timestamp_json() {
773        let raw = r#"{"text":"Hi there","segments":[{"start":0.0,"end":1.0,"text":"Hi there"}]}"#;
774        let (text, segs) = parse_chat_content(raw, true, 1.0);
775        assert_eq!(text, "Hi there");
776        assert_eq!(segs.len(), 1);
777        assert_eq!(segs[0].end, 1.0);
778    }
779
780    #[test]
781    fn auto_routes_whisper_to_transcriptions() {
782        let p = OpenRouterProvider::with_policy(
783            Some("k".into()),
784            Some("https://openrouter.ai/api/v1".into()),
785            RemotePolicy::default(),
786            OpenRouterSttMode::Auto,
787        )
788        .unwrap();
789        assert_eq!(
790            p.resolve_path("openai/whisper-large-v3"),
791            SttPath::Transcriptions
792        );
793        assert_eq!(p.resolve_path("google/gemini-2.5-flash"), SttPath::Chat);
794    }
795}