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