rig_core/providers/openrouter/
transcription.rs1use 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
11pub const WHISPER_1: &str = "openai/whisper-1";
17pub const WHISPER_LARGE_V3_TURBO: &str = "openai/whisper-large-v3-turbo";
19pub const WHISPER_LARGE_V3: &str = "openai/whisper-large-v3";
21pub const GPT_4O_TRANSCRIBE: &str = "openai/gpt-4o-transcribe";
23pub const GPT_4O_MINI_TRANSCRIBE: &str = "openai/gpt-4o-mini-transcribe";
25pub const CHIRP_3: &str = "google/chirp-3";
27
28#[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#[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}