Skip to main content

rig_core/providers/openrouter/
transcription.rs

1use crate::http_client::HttpClientExt;
2use crate::providers::internal::transcription::send_json_transcription;
3use crate::providers::openrouter::Client;
4use crate::transcription;
5use crate::transcription::TranscriptionError;
6use crate::wasm_compat::WasmCompatSend;
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD;
9use serde::Deserialize;
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#[derive(Debug, Deserialize)]
33pub struct TranscriptionResponse {
34    pub text: String,
35    #[serde(default)]
36    pub usage: Option<TranscriptionUsage>,
37}
38
39#[derive(Debug, Deserialize)]
40pub struct TranscriptionUsage {
41    #[serde(default)]
42    pub seconds: Option<f64>,
43    #[serde(default)]
44    pub total_tokens: Option<usize>,
45    #[serde(default)]
46    pub input_tokens: Option<usize>,
47    #[serde(default)]
48    pub output_tokens: Option<usize>,
49    #[serde(default)]
50    pub cost: Option<f64>,
51}
52
53impl TryFrom<TranscriptionResponse>
54    for transcription::TranscriptionResponse<TranscriptionResponse>
55{
56    type Error = TranscriptionError;
57
58    fn try_from(value: TranscriptionResponse) -> Result<Self, Self::Error> {
59        Ok(transcription::TranscriptionResponse {
60            text: value.text.clone(),
61            response: value,
62        })
63    }
64}
65
66// ================================================================
67// Model
68// ================================================================
69
70pub type TranscriptionModel<T = reqwest::Client> =
71    crate::providers::internal::transcription::GenericTranscriptionModel<
72        crate::providers::openrouter::client::OpenRouterExt,
73        T,
74    >;
75
76fn infer_format_from_filename(filename: &str) -> String {
77    std::path::Path::new(filename)
78        .extension()
79        .and_then(|e| e.to_str())
80        .and_then(|ext| match ext.to_lowercase().as_str() {
81            "wav" => Some("wav"),
82            "mp3" => Some("mp3"),
83            "flac" => Some("flac"),
84            "m4a" => Some("m4a"),
85            "ogg" => Some("ogg"),
86            "webm" => Some("webm"),
87            "aac" => Some("aac"),
88            _ => None,
89        })
90        .unwrap_or("wav")
91        .to_string()
92}
93
94impl<T> transcription::TranscriptionModel for TranscriptionModel<T>
95where
96    T: HttpClientExt + Clone + std::fmt::Debug + Default + WasmCompatSend + 'static,
97{
98    type Response = TranscriptionResponse;
99    type Client = Client<T>;
100
101    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
102        Self::new(client.clone(), model)
103    }
104
105    async fn transcription(
106        &self,
107        request: transcription::TranscriptionRequest,
108    ) -> Result<transcription::TranscriptionResponse<Self::Response>, TranscriptionError> {
109        if let Some(_prompt) = request.prompt {
110            return Err(TranscriptionError::RequestError(Box::new(
111                std::io::Error::new(
112                    std::io::ErrorKind::InvalidInput,
113                    "OpenRouter STT does not support a top-level prompt field. \
114                     Provider-specific prompt options can be passed via `additional_params`. \
115                     Example: {\"provider\": {\"options\": {\"<provider>\": {\"prompt\": \"<text>\"}}}}",
116                ),
117            )));
118        }
119
120        let audio_b64 = STANDARD.encode(&request.data);
121        let format = infer_format_from_filename(&request.filename);
122
123        let mut body_map: serde_json::Map<String, serde_json::Value> = [
124            ("model".to_string(), serde_json::json!(self.model)),
125            (
126                "input_audio".to_string(),
127                serde_json::json!({
128                    "data": audio_b64,
129                    "format": format,
130                }),
131            ),
132        ]
133        .into_iter()
134        .collect();
135
136        if let Some(language) = request.language {
137            body_map.insert("language".to_string(), serde_json::json!(language));
138        }
139        if let Some(temperature) = request.temperature {
140            body_map.insert("temperature".to_string(), serde_json::json!(temperature));
141        }
142
143        if let Some(ref additional_params) = request.additional_params {
144            let params = additional_params.as_object().ok_or_else(|| {
145                TranscriptionError::RequestError(Box::new(std::io::Error::new(
146                    std::io::ErrorKind::InvalidInput,
147                    "additional transcription parameters must be a JSON object",
148                )))
149            })?;
150            for (k, v) in params {
151                body_map.insert(k.clone(), v.clone());
152            }
153        }
154
155        let body = serde_json::to_vec(&serde_json::Value::Object(body_map))?;
156
157        send_json_transcription(
158            &self.client,
159            self.client
160                .post("/audio/transcriptions")?
161                .header("Content-Type", "application/json"),
162            body,
163            |_, body_bytes| {
164                let resp: TranscriptionResponse = serde_json::from_slice(body_bytes)?;
165                resp.try_into()
166            },
167        )
168        .await
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_infer_format_from_filename() {
178        assert_eq!(infer_format_from_filename("audio.wav"), "wav");
179        assert_eq!(infer_format_from_filename("audio.mp3"), "mp3");
180        assert_eq!(infer_format_from_filename("audio.flac"), "flac");
181        assert_eq!(infer_format_from_filename("audio.m4a"), "m4a");
182        assert_eq!(infer_format_from_filename("audio.ogg"), "ogg");
183        assert_eq!(infer_format_from_filename("audio.webm"), "webm");
184        assert_eq!(infer_format_from_filename("audio.aac"), "aac");
185        assert_eq!(infer_format_from_filename("audio.WAV"), "wav");
186        assert_eq!(infer_format_from_filename("audio.MP3"), "mp3");
187        assert_eq!(infer_format_from_filename("unknown"), "wav");
188        assert_eq!(infer_format_from_filename("noextension"), "wav");
189        assert_eq!(infer_format_from_filename("meeting.final.mp3"), "mp3");
190        assert_eq!(infer_format_from_filename("audio.tar.gz"), "wav");
191    }
192
193    #[test]
194    fn test_transcription_response_deserialization() {
195        let json = r#"{"text": "Hello world", "usage": {"seconds": 1.5, "cost": 0.001}}"#;
196        let resp: TranscriptionResponse = serde_json::from_str(json).unwrap();
197        assert_eq!(resp.text, "Hello world");
198        let usage = resp.usage.unwrap();
199        assert_eq!(usage.seconds, Some(1.5));
200    }
201
202    #[test]
203    fn test_transcription_response_without_usage() {
204        let json = r#"{"text": "Hello world"}"#;
205        let resp: TranscriptionResponse = serde_json::from_str(json).unwrap();
206        assert_eq!(resp.text, "Hello world");
207        assert!(resp.usage.is_none());
208    }
209
210    #[tokio::test]
211    async fn transcription_non_success_preserves_status_and_body() {
212        use crate::client::transcription::TranscriptionClient;
213        use crate::test_utils::RecordingHttpClient;
214        use crate::transcription::TranscriptionModel as _;
215
216        let body = r#"{"error":{"message":"boom"}}"#;
217        let http_client =
218            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
219        let client = Client::builder()
220            .api_key("test-key")
221            .http_client(http_client)
222            .build()
223            .expect("build client");
224        let model = client.transcription_model(WHISPER_1);
225
226        let request = model.transcription_request().data(vec![0u8; 16]).build();
227
228        let error = model
229            .transcription(request)
230            .await
231            .err()
232            .expect("should fail with non-success status");
233
234        assert!(matches!(error, TranscriptionError::HttpError(_)));
235        assert_eq!(
236            error.provider_response_status(),
237            Some(http::StatusCode::SERVICE_UNAVAILABLE)
238        );
239        assert_eq!(error.provider_response_body(), Some(body));
240    }
241}