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