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 = options.resolve_op_context();
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    use crate::remote::TimestampSource;
624
625    // Plain text response_format=text
626    if !body.trim_start().starts_with('{') {
627        let text = body.trim().to_string();
628        if text.is_empty() {
629            return Err(ProviderError::TranscriptionFailed {
630                reason: "empty transcription response".into(),
631            }
632            .into());
633        }
634        return Ok((
635            text.clone(),
636            vec![Segment::from_parts_with_source(
637                0.0,
638                duration,
639                text,
640                TimestampSource::SyntheticSpan,
641            )],
642            false,
643        ));
644    }
645
646    let parsed: TranscriptionsJson =
647        serde_json::from_str(body).map_err(|e| ProviderError::InvalidProviderPayload {
648            provider: PROVIDER_NAME.into(),
649            reason: format!("transcriptions JSON: {e}"),
650        })?;
651
652    let text = parsed.text.trim().to_string();
653    if text.is_empty() {
654        return Err(ProviderError::TranscriptionFailed {
655            reason: "empty transcription text".into(),
656        }
657        .into());
658    }
659
660    if want_timestamps {
661        if let Some(raw_segs) = parsed.segments {
662            let segments: Vec<Segment> = raw_segs
663                .into_iter()
664                .map(|s| {
665                    Segment::from_parts_with_source(
666                        s.start,
667                        s.end,
668                        s.text,
669                        TimestampSource::ProviderSegment,
670                    )
671                })
672                .collect();
673            // Dedicated verbose_json segments carry provider segment timing.
674            return Ok((text, segments, true));
675        }
676    }
677
678    Ok((
679        text.clone(),
680        vec![Segment::from_parts_with_source(
681            0.0,
682            duration,
683            text,
684            TimestampSource::SyntheticSpan,
685        )],
686        false,
687    ))
688}
689
690fn parse_chat_content(
691    content: &str,
692    want_timestamps: bool,
693    duration: f64,
694) -> (String, Vec<Segment>) {
695    use crate::remote::TimestampSource;
696
697    if want_timestamps {
698        let cleaned = content
699            .trim()
700            .trim_start_matches("```json")
701            .trim_start_matches("```")
702            .trim_end_matches("```")
703            .trim();
704        if let Ok(payload) = serde_json::from_str::<TimestampPayload>(cleaned) {
705            // LLM-invented timings are never provider-native; force Unavailable.
706            let segments: Vec<Segment> = payload
707                .segments
708                .into_iter()
709                .map(|s| {
710                    Segment::from_parts_with_source(
711                        s.start(),
712                        s.end(),
713                        s.text().to_string(),
714                        TimestampSource::Unavailable,
715                    )
716                })
717                .collect();
718            return (payload.text, segments);
719        }
720    }
721
722    let text = content.to_string();
723    let segments = vec![Segment::from_parts_with_source(
724        0.0,
725        duration,
726        text.clone(),
727        TimestampSource::SyntheticSpan,
728    )];
729    (text, segments)
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use std::sync::Arc;
736    use wiremock::matchers::{method, path};
737    use wiremock::{Mock, MockServer, ResponseTemplate};
738
739    #[test]
740    fn mode_parse() {
741        assert_eq!(
742            OpenRouterSttMode::parse("auto").unwrap(),
743            OpenRouterSttMode::Auto
744        );
745        assert_eq!(
746            OpenRouterSttMode::parse("transcriptions").unwrap(),
747            OpenRouterSttMode::Transcriptions
748        );
749        assert!(OpenRouterSttMode::parse("nope").is_err());
750    }
751
752    #[test]
753    fn dedicated_registry_lookup() {
754        // Registry-authoritative: only reviewed ASR ids, not name substrings.
755        assert!(looks_like_dedicated_asr("openai/whisper-1"));
756        assert!(looks_like_dedicated_asr("openai/gpt-4o-transcribe"));
757        assert!(!looks_like_dedicated_asr("google/gemini-2.5-flash"));
758        assert!(!looks_like_dedicated_asr(
759            "vendor/whisper-clone-experimental"
760        ));
761    }
762
763    #[tokio::test]
764    async fn missing_key_fails_early() {
765        let err = OpenRouterProvider::new(None, None).unwrap_err();
766        assert!(matches!(
767            err,
768            crate::error::TranscriptionError::User(UserError::MissingApiKey)
769        ));
770    }
771
772    #[tokio::test]
773    async fn parses_successful_chat_response() {
774        let server = MockServer::start().await;
775        Mock::given(method("POST"))
776            .and(path("/chat/completions"))
777            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
778                "choices": [{
779                    "message": { "content": "Hello from the cloud." }
780                }]
781            })))
782            .mount(&server)
783            .await;
784
785        let provider = OpenRouterProvider::with_policy(
786            Some("test-key".into()),
787            Some(server.uri()),
788            RemotePolicy {
789                allow_loopback_http: true,
790                ..Default::default()
791            },
792            OpenRouterSttMode::Chat,
793        )
794        .unwrap();
795
796        let samples: Arc<[f32]> = vec![0.0f32; 1600].into();
797        let input =
798            AudioInput::from_parts_unchecked(PathBuf::from("silent.wav"), samples, 16_000, 0.1);
799        let opts = TranscriptionOptions {
800            model: "google/gemini-2.5-flash".into(),
801            language: "en".into(),
802            timestamps: false,
803            cancel: None,
804            op: None,
805        };
806        let result = provider.transcribe(&input, &opts).await.unwrap();
807        assert_eq!(result.text(), "Hello from the cloud.");
808        assert_eq!(result.provider(), "openrouter");
809        assert_eq!(result.backend_kind(), BackendKind::LlmAssisted);
810        assert!(!result.timestamps_reliable());
811    }
812
813    #[tokio::test]
814    async fn dedicated_path_hits_transcriptions() {
815        let server = MockServer::start().await;
816        Mock::given(method("POST"))
817            .and(path("/audio/transcriptions"))
818            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
819                "text": "Dedicated ASR path works."
820            })))
821            .mount(&server)
822            .await;
823
824        let provider = OpenRouterProvider::with_policy(
825            Some("test-key".into()),
826            Some(server.uri()),
827            RemotePolicy {
828                allow_loopback_http: true,
829                ..Default::default()
830            },
831            OpenRouterSttMode::Transcriptions,
832        )
833        .unwrap();
834
835        let input = AudioInput::from_parts_unchecked(
836            PathBuf::from("x.wav"),
837            vec![0.0; 1600].into(),
838            16_000,
839            0.1,
840        );
841        let opts = TranscriptionOptions {
842            model: "openai/whisper-1".into(),
843            language: "en".into(),
844            timestamps: false,
845            cancel: None,
846            op: None,
847        };
848        let result = provider.transcribe(&input, &opts).await.unwrap();
849        assert_eq!(result.text(), "Dedicated ASR path works.");
850        assert_eq!(result.backend_kind(), BackendKind::Asr);
851    }
852
853    #[tokio::test]
854    async fn maps_rate_limit() {
855        let server = MockServer::start().await;
856        Mock::given(method("POST"))
857            .and(path("/chat/completions"))
858            .respond_with(ResponseTemplate::new(429).set_body_string("slow down"))
859            .mount(&server)
860            .await;
861
862        let provider = OpenRouterProvider::with_policy(
863            Some("test-key".into()),
864            Some(server.uri()),
865            RemotePolicy {
866                allow_loopback_http: true,
867                ..Default::default()
868            },
869            OpenRouterSttMode::Chat,
870        )
871        .unwrap();
872        let input = AudioInput::from_parts_unchecked(
873            PathBuf::from("x.wav"),
874            vec![0.0; 1600].into(),
875            16_000,
876            0.1,
877        );
878        let opts = TranscriptionOptions {
879            model: "google/gemini-2.5-flash".into(),
880            language: "auto".into(),
881            timestamps: false,
882            cancel: None,
883            op: None,
884        };
885        let err = provider.transcribe(&input, &opts).await.unwrap_err();
886        match err {
887            crate::error::TranscriptionError::Provider(ProviderError::RateLimited { .. }) => {}
888            other => panic!("expected rate limit, got {other}"),
889        }
890    }
891
892    #[test]
893    fn parse_timestamp_json() {
894        let raw = r#"{"text":"Hi there","segments":[{"start":0.0,"end":1.0,"text":"Hi there"}]}"#;
895        let (text, segs) = parse_chat_content(raw, true, 1.0);
896        assert_eq!(text, "Hi there");
897        assert_eq!(segs.len(), 1);
898        assert_eq!(segs[0].end, 1.0);
899    }
900
901    #[test]
902    fn auto_routes_whisper_to_transcriptions() {
903        let p = OpenRouterProvider::with_policy(
904            Some("k".into()),
905            Some("https://openrouter.ai/api/v1".into()),
906            RemotePolicy::default(),
907            OpenRouterSttMode::Auto,
908        )
909        .unwrap();
910        assert_eq!(
911            p.resolve_path("openai/whisper-large-v3").unwrap(),
912            SttPath::Transcriptions
913        );
914        assert_eq!(
915            p.resolve_path("google/gemini-2.5-flash").unwrap(),
916            SttPath::Chat
917        );
918    }
919
920    #[test]
921    fn auto_unknown_model_fails_closed() {
922        let p = OpenRouterProvider::with_policy(
923            Some("k".into()),
924            Some("https://openrouter.ai/api/v1".into()),
925            RemotePolicy::default(),
926            OpenRouterSttMode::Auto,
927        )
928        .unwrap();
929        let err = p.resolve_path("acme/unknown-model-v1").unwrap_err();
930        assert!(
931            err.to_string().contains("reviewed") || err.to_string().contains("unsupported"),
932            "unexpected: {err}"
933        );
934    }
935
936    #[test]
937    fn explicit_transcriptions_accepts_unregistered() {
938        let p = OpenRouterProvider::with_policy(
939            Some("k".into()),
940            Some("https://openrouter.ai/api/v1".into()),
941            RemotePolicy::default(),
942            OpenRouterSttMode::Transcriptions,
943        )
944        .unwrap();
945        assert_eq!(
946            p.resolve_path("vendor/custom-asr").unwrap(),
947            SttPath::Transcriptions
948        );
949    }
950}