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