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 {
556                start: 0.0,
557                end: duration,
558                text,
559            }],
560            false,
561        ));
562    }
563
564    let parsed: TranscriptionsJson =
565        serde_json::from_str(body).map_err(|e| ProviderError::InvalidProviderPayload {
566            provider: PROVIDER_NAME.into(),
567            reason: format!("transcriptions JSON: {e}"),
568        })?;
569
570    let text = parsed.text.trim().to_string();
571    if text.is_empty() {
572        return Err(ProviderError::TranscriptionFailed {
573            reason: "empty transcription text".into(),
574        }
575        .into());
576    }
577
578    if want_timestamps {
579        if let Some(raw_segs) = parsed.segments {
580            let segments: Vec<Segment> = raw_segs
581                .into_iter()
582                .map(|s| Segment {
583                    start: s.start,
584                    end: s.end,
585                    text: s.text,
586                })
587                .collect();
588            // Dedicated verbose_json segments are treated as engine-derived.
589            return Ok((text, segments, true));
590        }
591    }
592
593    Ok((
594        text.clone(),
595        vec![Segment {
596            start: 0.0,
597            end: duration,
598            text,
599        }],
600        false,
601    ))
602}
603
604fn parse_chat_content(
605    content: &str,
606    want_timestamps: bool,
607    duration: f64,
608) -> (String, Vec<Segment>) {
609    if want_timestamps {
610        let cleaned = content
611            .trim()
612            .trim_start_matches("```json")
613            .trim_start_matches("```")
614            .trim_end_matches("```")
615            .trim();
616        if let Ok(payload) = serde_json::from_str::<TimestampPayload>(cleaned) {
617            return (payload.text, payload.segments);
618        }
619    }
620
621    let text = content.to_string();
622    let segments = vec![Segment {
623        start: 0.0,
624        end: duration,
625        text: text.clone(),
626    }];
627    (text, segments)
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use std::sync::Arc;
634    use wiremock::matchers::{method, path};
635    use wiremock::{Mock, MockServer, ResponseTemplate};
636
637    #[test]
638    fn mode_parse() {
639        assert_eq!(
640            OpenRouterSttMode::parse("auto").unwrap(),
641            OpenRouterSttMode::Auto
642        );
643        assert_eq!(
644            OpenRouterSttMode::parse("transcriptions").unwrap(),
645            OpenRouterSttMode::Transcriptions
646        );
647        assert!(OpenRouterSttMode::parse("nope").is_err());
648    }
649
650    #[test]
651    fn dedicated_heuristics() {
652        assert!(looks_like_dedicated_asr("openai/whisper-1"));
653        assert!(looks_like_dedicated_asr("openai/gpt-4o-transcribe"));
654        assert!(!looks_like_dedicated_asr("google/gemini-2.5-flash"));
655    }
656
657    #[tokio::test]
658    async fn missing_key_fails_early() {
659        let err = OpenRouterProvider::new(None, None).unwrap_err();
660        assert!(matches!(
661            err,
662            crate::error::TranscriptionError::User(UserError::MissingApiKey)
663        ));
664    }
665
666    #[tokio::test]
667    async fn parses_successful_chat_response() {
668        let server = MockServer::start().await;
669        Mock::given(method("POST"))
670            .and(path("/chat/completions"))
671            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
672                "choices": [{
673                    "message": { "content": "Hello from the cloud." }
674                }]
675            })))
676            .mount(&server)
677            .await;
678
679        let provider = OpenRouterProvider::with_policy(
680            Some("test-key".into()),
681            Some(server.uri()),
682            RemotePolicy {
683                allow_loopback_http: true,
684                ..Default::default()
685            },
686            OpenRouterSttMode::Chat,
687        )
688        .unwrap();
689
690        let samples: Arc<[f32]> = vec![0.0f32; 1600].into();
691        let input = AudioInput {
692            source_path: PathBuf::from("silent.wav"),
693            samples,
694            sample_rate: 16_000,
695            duration_secs: 0.1,
696        };
697        let opts = TranscriptionOptions {
698            model: "google/gemini-2.5-flash".into(),
699            language: "en".into(),
700            timestamps: false,
701            cancel: None,
702        };
703        let result = provider.transcribe(&input, &opts).await.unwrap();
704        assert_eq!(result.text, "Hello from the cloud.");
705        assert_eq!(result.provider, "openrouter");
706        assert_eq!(result.backend_kind, BackendKind::LlmAssisted);
707        assert!(!result.timestamps_reliable);
708    }
709
710    #[tokio::test]
711    async fn dedicated_path_hits_transcriptions() {
712        let server = MockServer::start().await;
713        Mock::given(method("POST"))
714            .and(path("/audio/transcriptions"))
715            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
716                "text": "Dedicated ASR path works."
717            })))
718            .mount(&server)
719            .await;
720
721        let provider = OpenRouterProvider::with_policy(
722            Some("test-key".into()),
723            Some(server.uri()),
724            RemotePolicy {
725                allow_loopback_http: true,
726                ..Default::default()
727            },
728            OpenRouterSttMode::Transcriptions,
729        )
730        .unwrap();
731
732        let input = AudioInput {
733            source_path: PathBuf::from("x.wav"),
734            samples: vec![0.0; 1600].into(),
735            sample_rate: 16_000,
736            duration_secs: 0.1,
737        };
738        let opts = TranscriptionOptions {
739            model: "openai/whisper-1".into(),
740            language: "en".into(),
741            timestamps: false,
742            cancel: None,
743        };
744        let result = provider.transcribe(&input, &opts).await.unwrap();
745        assert_eq!(result.text, "Dedicated ASR path works.");
746        assert_eq!(result.backend_kind, BackendKind::Asr);
747    }
748
749    #[tokio::test]
750    async fn maps_rate_limit() {
751        let server = MockServer::start().await;
752        Mock::given(method("POST"))
753            .and(path("/chat/completions"))
754            .respond_with(ResponseTemplate::new(429).set_body_string("slow down"))
755            .mount(&server)
756            .await;
757
758        let provider = OpenRouterProvider::with_policy(
759            Some("test-key".into()),
760            Some(server.uri()),
761            RemotePolicy {
762                allow_loopback_http: true,
763                ..Default::default()
764            },
765            OpenRouterSttMode::Chat,
766        )
767        .unwrap();
768        let input = AudioInput {
769            source_path: PathBuf::from("x.wav"),
770            samples: vec![0.0; 1600].into(),
771            sample_rate: 16_000,
772            duration_secs: 0.1,
773        };
774        let opts = TranscriptionOptions {
775            model: "google/gemini-2.5-flash".into(),
776            language: "auto".into(),
777            timestamps: false,
778            cancel: None,
779        };
780        let err = provider.transcribe(&input, &opts).await.unwrap_err();
781        match err {
782            crate::error::TranscriptionError::Provider(ProviderError::RateLimited { .. }) => {}
783            other => panic!("expected rate limit, got {other}"),
784        }
785    }
786
787    #[test]
788    fn parse_timestamp_json() {
789        let raw = r#"{"text":"Hi there","segments":[{"start":0.0,"end":1.0,"text":"Hi there"}]}"#;
790        let (text, segs) = parse_chat_content(raw, true, 1.0);
791        assert_eq!(text, "Hi there");
792        assert_eq!(segs.len(), 1);
793        assert_eq!(segs[0].end, 1.0);
794    }
795
796    #[test]
797    fn auto_routes_whisper_to_transcriptions() {
798        let p = OpenRouterProvider::with_policy(
799            Some("k".into()),
800            Some("https://openrouter.ai/api/v1".into()),
801            RemotePolicy::default(),
802            OpenRouterSttMode::Auto,
803        )
804        .unwrap();
805        assert_eq!(
806            p.resolve_path("openai/whisper-large-v3"),
807            SttPath::Transcriptions
808        );
809        assert_eq!(p.resolve_path("google/gemini-2.5-flash"), SttPath::Chat);
810    }
811}