1use super::{
12 BackendKind, Segment, TranscriptionOptions, TranscriptionProvider, TranscriptionResult,
13};
14use crate::audio::{self, AudioInput, DEFAULT_MAX_UPLOAD_BYTES};
15use crate::error::{ProviderError, Result, UserError};
16use crate::postprocess::{self, truncate_chars};
17use async_trait::async_trait;
18use base64::Engine;
19use serde::{Deserialize, Serialize};
20use serde_json::json;
21use std::path::PathBuf;
22use std::time::Duration;
23
24const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
25const PROVIDER_NAME: &str = "openrouter";
26
27pub struct OpenRouterProvider {
29 api_key: String,
30 base_url: String,
31 http: reqwest::Client,
32 max_upload_bytes: usize,
33}
34
35impl std::fmt::Debug for OpenRouterProvider {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.debug_struct("OpenRouterProvider")
38 .field("base_url", &self.base_url)
39 .field("api_key", &"***")
40 .field("max_upload_bytes", &self.max_upload_bytes)
41 .finish()
42 }
43}
44
45impl OpenRouterProvider {
46 pub fn new(api_key: Option<String>, base_url: Option<String>) -> Result<Self> {
48 let api_key = api_key
49 .map(|s| s.trim().to_string())
50 .filter(|s| !s.is_empty())
51 .ok_or(UserError::MissingApiKey)?;
52
53 let base_url = base_url
54 .map(|s| s.trim_end_matches('/').to_string())
55 .filter(|s| !s.is_empty())
56 .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
57
58 let http = reqwest::Client::builder()
59 .user_agent(concat!("aurum/", env!("CARGO_PKG_VERSION")))
60 .timeout(Duration::from_secs(600))
61 .build()
62 .map_err(|e| ProviderError::Network {
63 provider: PROVIDER_NAME.into(),
64 reason: e.to_string(),
65 })?;
66
67 Ok(Self {
68 api_key,
69 base_url,
70 http,
71 max_upload_bytes: DEFAULT_MAX_UPLOAD_BYTES,
72 })
73 }
74
75 fn chat_url(&self) -> String {
76 format!("{}/chat/completions", self.base_url)
77 }
78}
79
80#[async_trait]
81impl TranscriptionProvider for OpenRouterProvider {
82 fn name(&self) -> &'static str {
83 PROVIDER_NAME
84 }
85
86 fn backend_kind(&self) -> BackendKind {
87 BackendKind::LlmAssisted
88 }
89
90 async fn transcribe(
91 &self,
92 input: &AudioInput,
93 options: &TranscriptionOptions,
94 ) -> Result<TranscriptionResult> {
95 let (upload_path, format) =
97 audio::encode_for_upload(&input.samples, self.max_upload_bytes).await?;
98 let cleanup = scopeguard_path(upload_path.clone());
99
100 let file_bytes = tokio::fs::read(&upload_path).await?;
101 if file_bytes.len() > self.max_upload_bytes {
102 return Err(UserError::AudioTooLarge {
103 decoded_bytes: file_bytes.len(),
104 max_bytes: self.max_upload_bytes,
105 }
106 .into());
107 }
108 let b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes);
109 drop(cleanup); let mut prompt =
112 String::from("Transcribe the audio verbatim. Reply with ONLY the transcript text");
113 if options.timestamps {
114 prompt.push_str(
115 ", as a JSON object with keys \"text\" (string) and \"segments\" \
116 (array of {\"start\": number, \"end\": number, \"text\": string}) \
117 where times are in seconds. Do not wrap in markdown. \
118 If you cannot produce reliable timestamps, return text only as plain string.",
119 );
120 } else {
121 prompt.push_str(". Do not add commentary, labels, or markdown.");
122 }
123
124 let lang = options.language.trim().to_ascii_lowercase();
125 if !lang.is_empty() && lang != "auto" {
126 prompt.push_str(&format!(" The audio language is \"{lang}\"."));
127 }
128
129 let body = json!({
130 "model": options.model,
131 "messages": [{
132 "role": "user",
133 "content": [
134 { "type": "text", "text": prompt },
135 {
136 "type": "input_audio",
137 "input_audio": {
138 "data": b64,
139 "format": format
140 }
141 }
142 ]
143 }],
144 "temperature": 0,
145 "top_p": 1,
147 });
148
149 tracing::debug!(
150 model = %options.model,
151 url = %self.chat_url(),
152 upload_format = format,
153 upload_bytes = file_bytes.len(),
154 "openrouter request"
155 );
156
157 let response = self
158 .http
159 .post(self.chat_url())
160 .header("Authorization", format!("Bearer {}", self.api_key))
161 .header("Content-Type", "application/json")
162 .header("HTTP-Referer", "https://github.com/joe-broadhead/aurum")
163 .header("X-Title", "Aurum")
164 .json(&body)
165 .send()
166 .await
167 .map_err(|e| ProviderError::Network {
168 provider: PROVIDER_NAME.into(),
169 reason: e.to_string(),
170 })?;
171
172 let status = response.status();
173 let body_text = response.text().await.map_err(|e| ProviderError::Network {
174 provider: PROVIDER_NAME.into(),
175 reason: e.to_string(),
176 })?;
177
178 if status.as_u16() == 401 || status.as_u16() == 403 {
179 return Err(ProviderError::Auth {
180 provider: PROVIDER_NAME.into(),
181 reason: truncate_chars(&body_text, 300),
182 }
183 .into());
184 }
185 if status.as_u16() == 429 {
186 return Err(ProviderError::RateLimited {
187 provider: PROVIDER_NAME.into(),
188 }
189 .into());
190 }
191 if status.as_u16() == 402 {
192 return Err(ProviderError::QuotaExceeded {
193 provider: PROVIDER_NAME.into(),
194 reason: truncate_chars(&body_text, 300),
195 }
196 .into());
197 }
198 if !status.is_success() {
199 return Err(ProviderError::Remote {
200 provider: PROVIDER_NAME.into(),
201 reason: format!("HTTP {status}: {}", truncate_chars(&body_text, 500)),
202 }
203 .into());
204 }
205
206 let parsed: ChatCompletionResponse =
207 serde_json::from_str(&body_text).map_err(|e| ProviderError::Remote {
208 provider: PROVIDER_NAME.into(),
209 reason: format!(
210 "invalid JSON response: {e}; body={}",
211 truncate_chars(&body_text, 300)
212 ),
213 })?;
214
215 let content = parsed
216 .choices
217 .first()
218 .and_then(|c| c.message.content.as_deref())
219 .unwrap_or("")
220 .trim()
221 .to_string();
222
223 if content.is_empty() {
224 return Err(ProviderError::TranscriptionFailed {
225 reason: "OpenRouter returned an empty transcript".into(),
226 }
227 .into());
228 }
229
230 let (text, segments) = parse_content(&content, options.timestamps, input.duration_secs);
231
232 let result = TranscriptionResult::openrouter(
233 text,
234 segments,
235 if lang != "auto" && !lang.is_empty() {
236 Some(lang)
237 } else {
238 None
239 },
240 options.model.clone(),
241 input.duration_secs,
242 options.timestamps,
243 );
244
245 Ok(postprocess::normalize_result(result))
246 }
247}
248
249struct PathGuard(PathBuf);
251impl Drop for PathGuard {
252 fn drop(&mut self) {
253 let _ = std::fs::remove_file(&self.0);
254 }
255}
256fn scopeguard_path(path: PathBuf) -> PathGuard {
257 PathGuard(path)
258}
259
260#[derive(Debug, Deserialize)]
261struct ChatCompletionResponse {
262 choices: Vec<Choice>,
263}
264
265#[derive(Debug, Deserialize)]
266struct Choice {
267 message: Message,
268}
269
270#[derive(Debug, Deserialize)]
271struct Message {
272 content: Option<String>,
273}
274
275#[derive(Debug, Deserialize, Serialize)]
276struct TimestampPayload {
277 text: String,
278 #[serde(default)]
279 segments: Vec<Segment>,
280}
281
282fn parse_content(content: &str, want_timestamps: bool, duration: f64) -> (String, Vec<Segment>) {
283 if want_timestamps {
284 let cleaned = content
285 .trim()
286 .trim_start_matches("```json")
287 .trim_start_matches("```")
288 .trim_end_matches("```")
289 .trim();
290 if let Ok(payload) = serde_json::from_str::<TimestampPayload>(cleaned) {
291 return (payload.text, payload.segments);
292 }
293 }
294
295 let text = content.to_string();
296 let segments = vec![Segment {
297 start: 0.0,
298 end: duration,
299 text: text.clone(),
300 }];
301 (text, segments)
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use std::sync::Arc;
308 use wiremock::matchers::{method, path};
309 use wiremock::{Mock, MockServer, ResponseTemplate};
310
311 #[tokio::test]
312 async fn missing_key_fails_early() {
313 let err = OpenRouterProvider::new(None, None).unwrap_err();
314 assert!(matches!(
315 err,
316 crate::error::TranscriptionError::User(UserError::MissingApiKey)
317 ));
318 }
319
320 #[tokio::test]
321 async fn parses_successful_response() {
322 let server = MockServer::start().await;
323 Mock::given(method("POST"))
324 .and(path("/chat/completions"))
325 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
326 "choices": [{
327 "message": { "content": "Hello from the cloud." }
328 }]
329 })))
330 .mount(&server)
331 .await;
332
333 let provider =
334 OpenRouterProvider::new(Some("test-key".into()), Some(server.uri())).unwrap();
335
336 let samples: Arc<[f32]> = vec![0.0f32; 1600].into();
337 let input = AudioInput {
338 source_path: PathBuf::from("silent.wav"),
339 samples,
340 sample_rate: 16_000,
341 duration_secs: 0.1,
342 };
343 let opts = TranscriptionOptions {
344 model: "google/gemini-2.5-flash".into(),
345 language: "en".into(),
346 timestamps: false,
347 cancel: None,
348 };
349 let result = provider.transcribe(&input, &opts).await.unwrap();
350 assert_eq!(result.text, "Hello from the cloud.");
351 assert_eq!(result.provider, "openrouter");
352 assert_eq!(result.language.as_deref(), Some("en"));
353 }
354
355 #[tokio::test]
356 async fn maps_rate_limit() {
357 let server = MockServer::start().await;
358 Mock::given(method("POST"))
359 .and(path("/chat/completions"))
360 .respond_with(ResponseTemplate::new(429).set_body_string("slow down"))
361 .mount(&server)
362 .await;
363
364 let provider =
365 OpenRouterProvider::new(Some("test-key".into()), Some(server.uri())).unwrap();
366 let input = AudioInput {
367 source_path: PathBuf::from("x.wav"),
368 samples: vec![0.0; 1600].into(),
369 sample_rate: 16_000,
370 duration_secs: 0.1,
371 };
372 let opts = TranscriptionOptions {
373 model: "google/gemini-2.5-flash".into(),
374 language: "auto".into(),
375 timestamps: false,
376 cancel: None,
377 };
378 let err = provider.transcribe(&input, &opts).await.unwrap_err();
379 match err {
380 crate::error::TranscriptionError::Provider(ProviderError::RateLimited { .. }) => {}
381 other => panic!("expected rate limit, got {other}"),
382 }
383 }
384
385 #[test]
386 fn parse_timestamp_json() {
387 let raw = r#"{"text":"Hi there","segments":[{"start":0.0,"end":1.0,"text":"Hi there"}]}"#;
388 let (text, segs) = parse_content(raw, true, 1.0);
389 assert_eq!(text, "Hi there");
390 assert_eq!(segs.len(), 1);
391 assert_eq!(segs[0].end, 1.0);
392 }
393}