Skip to main content

rig_core/providers/openrouter/
transcription.rs

1use crate::http_client::HttpClientExt;
2use crate::providers::openrouter::Client;
3use crate::transcription;
4use crate::transcription::TranscriptionError;
5use crate::wasm_compat::WasmCompatSend;
6use base64::Engine;
7use base64::engine::general_purpose::STANDARD;
8use bytes::Bytes;
9use serde::{Deserialize, Serialize};
10
11// ================================================================
12// Model constants
13// ================================================================
14
15/// The `openai/whisper-1` model.
16pub const WHISPER_1: &str = "openai/whisper-1";
17/// The `openai/whisper-large-v3-turbo` model.
18pub const WHISPER_LARGE_V3_TURBO: &str = "openai/whisper-large-v3-turbo";
19/// The `openai/whisper-large-v3` model.
20pub const WHISPER_LARGE_V3: &str = "openai/whisper-large-v3";
21/// The `openai/gpt-4o-transcribe` model.
22pub const GPT_4O_TRANSCRIBE: &str = "openai/gpt-4o-transcribe";
23/// The `openai/gpt-4o-mini-transcribe` model.
24pub const GPT_4O_MINI_TRANSCRIBE: &str = "openai/gpt-4o-mini-transcribe";
25/// The `google/chirp-3` model.
26pub const CHIRP_3: &str = "google/chirp-3";
27
28// ================================================================
29// Request/Response types
30// ================================================================
31
32#[allow(dead_code)]
33#[derive(Debug, Serialize)]
34struct InputAudio {
35    data: String,
36    format: String,
37}
38
39#[allow(dead_code)]
40#[derive(Debug, Serialize)]
41struct TranscriptionRequestInput {
42    model: String,
43    input_audio: InputAudio,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    language: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    temperature: Option<f64>,
48}
49
50#[derive(Debug, Deserialize)]
51pub struct TranscriptionResponse {
52    pub text: String,
53    #[serde(default)]
54    pub usage: Option<TranscriptionUsage>,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct TranscriptionUsage {
59    #[serde(default)]
60    pub seconds: Option<f64>,
61    #[serde(default)]
62    pub total_tokens: Option<usize>,
63    #[serde(default)]
64    pub input_tokens: Option<usize>,
65    #[serde(default)]
66    pub output_tokens: Option<usize>,
67    #[serde(default)]
68    pub cost: Option<f64>,
69}
70
71impl TryFrom<TranscriptionResponse>
72    for transcription::TranscriptionResponse<TranscriptionResponse>
73{
74    type Error = TranscriptionError;
75
76    fn try_from(value: TranscriptionResponse) -> Result<Self, Self::Error> {
77        Ok(transcription::TranscriptionResponse {
78            text: value.text.clone(),
79            response: value,
80        })
81    }
82}
83
84// ================================================================
85// Model
86// ================================================================
87
88#[derive(Clone)]
89pub struct TranscriptionModel<T = reqwest::Client> {
90    client: Client<T>,
91    pub model: String,
92}
93
94impl<T> TranscriptionModel<T> {
95    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
96        Self {
97            client,
98            model: model.into(),
99        }
100    }
101}
102
103fn infer_format_from_filename(filename: &str) -> String {
104    std::path::Path::new(filename)
105        .extension()
106        .and_then(|e| e.to_str())
107        .and_then(|ext| match ext.to_lowercase().as_str() {
108            "wav" => Some("wav"),
109            "mp3" => Some("mp3"),
110            "flac" => Some("flac"),
111            "m4a" => Some("m4a"),
112            "ogg" => Some("ogg"),
113            "webm" => Some("webm"),
114            "aac" => Some("aac"),
115            _ => None,
116        })
117        .unwrap_or("wav")
118        .to_string()
119}
120
121impl<T> transcription::TranscriptionModel for TranscriptionModel<T>
122where
123    T: HttpClientExt + Clone + std::fmt::Debug + Default + WasmCompatSend + 'static,
124{
125    type Response = TranscriptionResponse;
126    type Client = Client<T>;
127
128    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
129        Self::new(client.clone(), model)
130    }
131
132    async fn transcription(
133        &self,
134        request: transcription::TranscriptionRequest,
135    ) -> Result<transcription::TranscriptionResponse<Self::Response>, TranscriptionError> {
136        if let Some(_prompt) = request.prompt {
137            return Err(TranscriptionError::RequestError(Box::new(
138                std::io::Error::new(
139                    std::io::ErrorKind::InvalidInput,
140                    "OpenRouter STT does not support a top-level prompt field. \
141                     Provider-specific prompt options can be passed via `additional_params`. \
142                     Example: {\"provider\": {\"options\": {\"<provider>\": {\"prompt\": \"<text>\"}}}}",
143                ),
144            )));
145        }
146
147        let audio_b64 = STANDARD.encode(&request.data);
148        let format = infer_format_from_filename(&request.filename);
149
150        let mut body_map: serde_json::Map<String, serde_json::Value> = [
151            ("model".to_string(), serde_json::json!(self.model)),
152            (
153                "input_audio".to_string(),
154                serde_json::json!({
155                    "data": audio_b64,
156                    "format": format,
157                }),
158            ),
159        ]
160        .into_iter()
161        .collect();
162
163        if let Some(language) = request.language {
164            body_map.insert("language".to_string(), serde_json::json!(language));
165        }
166        if let Some(temperature) = request.temperature {
167            body_map.insert("temperature".to_string(), serde_json::json!(temperature));
168        }
169
170        if let Some(ref additional_params) = request.additional_params {
171            let params = additional_params.as_object().ok_or_else(|| {
172                TranscriptionError::RequestError(Box::new(std::io::Error::new(
173                    std::io::ErrorKind::InvalidInput,
174                    "additional transcription parameters must be a JSON object",
175                )))
176            })?;
177            for (k, v) in params {
178                body_map.insert(k.clone(), v.clone());
179            }
180        }
181
182        let body = serde_json::to_vec(&serde_json::Value::Object(body_map))?;
183
184        let req = self
185            .client
186            .post("/audio/transcriptions")?
187            .header("Content-Type", "application/json")
188            .body(body)
189            .map_err(|e| TranscriptionError::HttpError(e.into()))?;
190
191        let response = self.client.send::<_, Bytes>(req).await?;
192        let status = response.status();
193        let body_bytes = response.into_body().await?;
194
195        if status.is_success() {
196            let resp: TranscriptionResponse = serde_json::from_slice(&body_bytes)?;
197            resp.try_into()
198        } else {
199            Err(TranscriptionError::from_http_response(
200                status,
201                String::from_utf8_lossy(&body_bytes),
202            ))
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_infer_format_from_filename() {
213        assert_eq!(infer_format_from_filename("audio.wav"), "wav");
214        assert_eq!(infer_format_from_filename("audio.mp3"), "mp3");
215        assert_eq!(infer_format_from_filename("audio.flac"), "flac");
216        assert_eq!(infer_format_from_filename("audio.m4a"), "m4a");
217        assert_eq!(infer_format_from_filename("audio.ogg"), "ogg");
218        assert_eq!(infer_format_from_filename("audio.webm"), "webm");
219        assert_eq!(infer_format_from_filename("audio.aac"), "aac");
220        assert_eq!(infer_format_from_filename("audio.WAV"), "wav");
221        assert_eq!(infer_format_from_filename("audio.MP3"), "mp3");
222        assert_eq!(infer_format_from_filename("unknown"), "wav");
223        assert_eq!(infer_format_from_filename("noextension"), "wav");
224        assert_eq!(infer_format_from_filename("meeting.final.mp3"), "mp3");
225        assert_eq!(infer_format_from_filename("audio.tar.gz"), "wav");
226    }
227
228    #[test]
229    fn test_transcription_request_serialization() {
230        let audio_b64 = STANDARD.encode(b"test audio data");
231        let req = TranscriptionRequestInput {
232            model: "openai/whisper-1".to_string(),
233            input_audio: InputAudio {
234                data: audio_b64,
235                format: "mp3".to_string(),
236            },
237            language: Some("en".to_string()),
238            temperature: None,
239        };
240        let json = serde_json::to_string(&req).unwrap();
241        assert!(json.contains("\"model\":\"openai/whisper-1\""));
242        assert!(json.contains("\"input_audio\""));
243        assert!(json.contains("\"language\":\"en\""));
244    }
245
246    #[test]
247    fn test_transcription_response_deserialization() {
248        let json = r#"{"text": "Hello world", "usage": {"seconds": 1.5, "cost": 0.001}}"#;
249        let resp: TranscriptionResponse = serde_json::from_str(json).unwrap();
250        assert_eq!(resp.text, "Hello world");
251        let usage = resp.usage.unwrap();
252        assert_eq!(usage.seconds, Some(1.5));
253    }
254
255    #[test]
256    fn test_transcription_response_without_usage() {
257        let json = r#"{"text": "Hello world"}"#;
258        let resp: TranscriptionResponse = serde_json::from_str(json).unwrap();
259        assert_eq!(resp.text, "Hello world");
260        assert!(resp.usage.is_none());
261    }
262
263    #[tokio::test]
264    async fn transcription_non_success_preserves_status_and_body() {
265        use crate::client::transcription::TranscriptionClient;
266        use crate::test_utils::RecordingHttpClient;
267        use crate::transcription::TranscriptionModel as _;
268
269        let body = r#"{"error":{"message":"boom"}}"#;
270        let http_client =
271            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
272        let client = Client::builder()
273            .api_key("test-key")
274            .http_client(http_client)
275            .build()
276            .expect("build client");
277        let model = client.transcription_model(WHISPER_1);
278
279        let request = model.transcription_request().data(vec![0u8; 16]).build();
280
281        let error = model
282            .transcription(request)
283            .await
284            .err()
285            .expect("should fail with non-success status");
286
287        assert!(matches!(error, TranscriptionError::HttpError(_)));
288        assert_eq!(
289            error.provider_response_status(),
290            Some(http::StatusCode::SERVICE_UNAVAILABLE)
291        );
292        assert_eq!(error.provider_response_body(), Some(body));
293    }
294}