rig_core/providers/openai/
transcription.rs1use crate::http_client::HttpClientExt;
2use crate::providers::internal::transcription::OpenAiTranscriptionClient;
3use crate::providers::openai::{Client, CompletionsClient};
4use crate::transcription;
5use crate::transcription::TranscriptionError;
6use serde::Deserialize;
7
8pub const WHISPER_1: &str = "whisper-1";
13
14#[derive(Debug, Deserialize)]
15pub struct TranscriptionResponse {
16 pub text: String,
17 #[serde(default)]
25 pub usage: Option<TranscriptionUsage>,
26}
27
28#[derive(Debug, Clone, Deserialize, PartialEq)]
43#[serde(untagged)]
44pub enum TranscriptionUsage {
45 Duration {
47 r#type: DurationTag,
49 seconds: f64,
51 },
52 Tokens {
54 r#type: TokensTag,
56 input_tokens: u64,
58 #[serde(default)]
62 input_token_details: Option<TranscriptionInputTokenDetails>,
63 output_tokens: u64,
65 total_tokens: u64,
67 },
68 Other(serde_json::Value),
70}
71
72#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum DurationTag {
76 Duration,
78}
79
80#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum TokensTag {
84 Tokens,
86}
87
88#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
90pub struct TranscriptionInputTokenDetails {
91 #[serde(default)]
93 pub audio_tokens: u64,
94 #[serde(default)]
96 pub text_tokens: u64,
97}
98
99impl TryFrom<TranscriptionResponse>
100 for transcription::TranscriptionResponse<TranscriptionResponse>
101{
102 type Error = TranscriptionError;
103
104 fn try_from(value: TranscriptionResponse) -> Result<Self, Self::Error> {
105 Ok(transcription::TranscriptionResponse {
106 text: value.text.clone(),
107 response: value,
108 })
109 }
110}
111
112pub type TranscriptionModel<T = reqwest::Client> =
114 crate::providers::internal::transcription::OpenAiTranscriptionModel<Client<T>>;
115
116pub type CompletionsTranscriptionModel<T = reqwest::Client> =
118 crate::providers::internal::transcription::OpenAiTranscriptionModel<CompletionsClient<T>>;
119
120impl<T> OpenAiTranscriptionClient for Client<T>
121where
122 T: HttpClientExt + Clone + 'static,
123{
124 const MODEL_IN_FORM: bool = true;
125
126 fn transcription_request(
127 &self,
128 _model: &str,
129 ) -> crate::http_client::Result<crate::http_client::Builder> {
130 self.post("/audio/transcriptions")
131 }
132}
133
134impl<T> OpenAiTranscriptionClient for CompletionsClient<T>
135where
136 T: HttpClientExt + Clone + 'static,
137{
138 const MODEL_IN_FORM: bool = true;
139
140 fn transcription_request(
141 &self,
142 _model: &str,
143 ) -> crate::http_client::Result<crate::http_client::Builder> {
144 self.post("/audio/transcriptions")
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::client::transcription::TranscriptionClient;
152 use crate::test_utils::RecordingHttpClient;
153 use crate::transcription::TranscriptionModel as _;
154
155 #[tokio::test]
156 async fn transcription_routes_model_in_multipart_body() {
157 let http_client = RecordingHttpClient::new(r#"{"text":"transcribed"}"#);
158 let client = Client::builder()
159 .api_key("test-key")
160 .http_client(http_client.clone())
161 .build()
162 .expect("build client");
163 let model = client.transcription_model(WHISPER_1);
164
165 let response = model
166 .transcription_request()
167 .data(vec![1, 2, 3])
168 .filename(Some("audio.mp3".to_owned()))
169 .send()
170 .await
171 .expect("transcription should succeed");
172
173 assert_eq!(response.text, "transcribed");
174 let request = http_client
175 .requests()
176 .into_iter()
177 .next()
178 .expect("request should be captured");
179 assert_eq!(
180 request.uri,
181 "https://api.openai.com/v1/audio/transcriptions"
182 );
183 let body = String::from_utf8_lossy(&request.body);
184 assert!(
185 body.contains("name=\"model\"\r\n\r\nwhisper-1\r\n"),
186 "{body}"
187 );
188 assert!(
189 body.contains("name=\"file\"; filename=\"audio.mp3\""),
190 "{body}"
191 );
192 }
193
194 #[test]
199 fn usage_decodes_both_billing_shapes_and_keeps_unknown_ones() {
200 fn usage(body: &str) -> Option<TranscriptionUsage> {
201 serde_json::from_str::<TranscriptionResponse>(body)
202 .expect("response should decode")
203 .usage
204 }
205
206 assert_eq!(
207 usage(r#"{"text":"hi","usage":{"type":"duration","seconds":6}}"#),
208 Some(TranscriptionUsage::Duration {
209 r#type: DurationTag::Duration,
210 seconds: 6.0
211 })
212 );
213 assert_eq!(
214 usage(
215 r#"{"text":"hi","usage":{"type":"tokens","input_tokens":54,
216 "input_token_details":{"audio_tokens":54,"text_tokens":0},
217 "output_tokens":16,"total_tokens":70}}"#
218 ),
219 Some(TranscriptionUsage::Tokens {
220 r#type: TokensTag::Tokens,
221 input_tokens: 54,
222 input_token_details: Some(TranscriptionInputTokenDetails {
223 audio_tokens: 54,
224 text_tokens: 0,
225 }),
226 output_tokens: 16,
227 total_tokens: 70,
228 })
229 );
230 assert_eq!(
233 usage(
234 r#"{"text":"hi","usage":{"type":"tokens","input_tokens":54,
235 "output_tokens":16,"total_tokens":70}}"#
236 ),
237 Some(TranscriptionUsage::Tokens {
238 r#type: TokensTag::Tokens,
239 input_tokens: 54,
240 input_token_details: None,
241 output_tokens: 16,
242 total_tokens: 70,
243 })
244 );
245 assert!(matches!(
249 usage(
250 r#"{"text":"hi","usage":{"type":"tokens","seconds":6,"input_tokens":54,
251 "output_tokens":16,"total_tokens":70}}"#
252 ),
253 Some(TranscriptionUsage::Tokens {
254 total_tokens: 70,
255 ..
256 })
257 ));
258 assert!(matches!(
259 usage(r#"{"text":"hi","usage":{"type":"credits","spent":3}}"#),
260 Some(TranscriptionUsage::Other(_))
261 ));
262 assert!(matches!(
265 usage(r#"{"text":"hi","usage":{"type":"tokens","input_tokens":54}}"#),
266 Some(TranscriptionUsage::Other(_))
267 ));
268 assert_eq!(usage(r#"{"text":"hi"}"#), None);
269 assert_eq!(usage(r#"{"text":"hi","usage":null}"#), None);
270 }
271
272 #[tokio::test]
273 async fn transcription_http_non_success_preserves_status_and_body() {
274 let body = r#"{"error":{"message":"bad audio","type":"invalid_request_error"}}"#;
275 let http_client =
276 RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
277 let client = Client::builder()
278 .api_key("test-key")
279 .http_client(http_client)
280 .build()
281 .expect("build client");
282 let model = client.transcription_model(WHISPER_1);
283
284 let error = match model
285 .transcription_request()
286 .data(vec![0u8; 16])
287 .send()
288 .await
289 {
290 Err(error) => error,
291 Ok(_) => panic!("transcription should fail with non-success status"),
292 };
293
294 assert!(matches!(error, TranscriptionError::HttpError(_)));
295 assert_eq!(
296 error.provider_response_status(),
297 Some(http::StatusCode::BAD_REQUEST)
298 );
299 assert_eq!(error.provider_response_body(), Some(body));
300 }
301}