Skip to main content

aurum_core/providers/
openrouter.rs

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