1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum OpenRouterSttMode {
37 #[default]
39 Auto,
40 Chat,
42 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
71pub 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 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 governor: ResourceGovernor::process_global(),
132 })
133 }
134
135 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum SttPath {
161 Chat,
162 Transcriptions,
163}
164
165pub 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 BackendKind::LlmAssisted
184 }
185
186 async fn transcribe(
187 &self,
188 input: &AudioInput,
189 options: &TranscriptionOptions,
190 ) -> Result<TranscriptionResult> {
191 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 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 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 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 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 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 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 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 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 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 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 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}