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