Skip to main content

aurum_core/providers/
openrouter.rs

1//! OpenRouter remote transcription provider.
2//!
3//! OpenRouter does not currently expose an OpenAI-compatible
4//! `/audio/transcriptions` endpoint. Instead, audio is sent via the
5//! multimodal chat completions API (`input_audio`).
6//!
7//! **Important product semantics:** this is LLM-assisted transcription, not a
8//! dedicated ASR model. Output may paraphrase, omit filler, or invent
9//! timestamps. Prefer the local provider when verbatim accuracy matters.
10
11use 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
27/// OpenRouter provider using multimodal chat completions for transcription.
28pub 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    /// Create a provider. Fails early if the API key is missing/empty.
47    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        // Compress before base64 — raw WAV in JSON OOMs on long audio.
96        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); // remove temp file as soon as encoded
110
111        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            // Required by some providers (e.g. Voxtral) when temperature is 0.
146            "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
249/// Tiny RAII helper so temp upload files are removed even on early return.
250struct 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}