openai_interface/audio/transcriptions.rs
1//! Transcribes audio into the input language.
2//!
3//! Endpoint: `POST /audio/transcriptions` (multipart/form-data request).
4//!
5//! Response shapes depend on `response_format`:
6//!
7//! - `json` and `verbose_json` deserialize into the typed
8//! [`TranscriptionResponse`] via `get_response`.
9//! - `text`, `srt`, and `vtt` return plain text; use
10//! `get_response_string` for those.
11//!
12//! Streaming transcriptions (`stream`) is not supported yet.
13//!
14//! > ![warn] This module is untested!
15//! > No OpenAI-compatible provider accessible to this project implements
16//! > this endpoint, and no OpenAI API key was available for testing. If you
17//! > encounter any issues, please report them on the repository.
18
19use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22use url::Url;
23
24use crate::{
25 audio::AudioResponseFormat,
26 errors::OapiError,
27 rest::RequestOptions,
28 rest::post::{Post, PostNoStream},
29};
30
31/// Transcribes audio into the input language.
32///
33/// The `keywords`, `languages`, `known_speaker_names`, and
34/// `known_speaker_references` parameters are not covered by this type yet.
35#[derive(Debug, Serialize, Default, Clone)]
36pub struct TranscriptionRequest {
37 /// The audio file (as a path) to transcribe, in one of these formats:
38 /// flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm. The request must
39 /// include enough format metadata for the file to be identified; an
40 /// extension-bearing filename satisfies this.
41 #[serde(skip_serializing)]
42 pub file: PathBuf,
43 /// ID of the model to use. The options are `gpt-transcribe`,
44 /// `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `whisper-1` (which is
45 /// powered by the open source Whisper V2 model), and
46 /// `gpt-4o-transcribe-diarize`.
47 pub model: String,
48 /// The language of the input audio. Supplying the input language in
49 /// [ISO-639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
50 /// (e.g. `en`) format will improve accuracy and latency.
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub language: Option<String>,
53 /// An optional text to guide the model's style or continue a previous
54 /// audio segment. The prompt should match the audio language.
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub prompt: Option<String>,
57 /// The format of the output, in one of these options: `json`, `text`,
58 /// `srt`, `verbose_json`, or `vtt`. For `gpt-4o-transcribe` and
59 /// `gpt-4o-mini-transcribe`, the only supported format is `json`.
60 ///
61 /// With `json` / `verbose_json`, use `get_response`; with `text` /
62 /// `srt` / `vtt`, use `get_response_string`.
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub response_format: Option<AudioResponseFormat>,
65 /// The sampling temperature, between 0 and 1. Higher values like 0.8
66 /// will make the output more random, while lower values like 0.2 will
67 /// make it more focused and deterministic.
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub temperature: Option<f32>,
70 /// The timestamp granularities to populate for this transcription.
71 /// `response_format` must be set to `verbose_json` to use timestamp
72 /// granularities. Either or both of these options are supported: `word`,
73 /// or `segment`.
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub timestamp_granularities: Option<Vec<TimestampGranularity>>,
76 /// Controls how the audio is cut into chunks.
77 ///
78 /// When set to `auto`, the server first normalizes loudness and then
79 /// uses voice activity detection (VAD) to choose boundaries. A
80 /// `server_vad` object can be provided to tweak VAD detection
81 /// parameters manually. If unset, the audio is transcribed as a single
82 /// block.
83 #[serde(skip_serializing)]
84 pub chunking_strategy: Option<ChunkingStrategy>,
85 /// Additional information to include in the transcription response.
86 /// `logprobs` will return the log probabilities of the tokens in the
87 /// response.
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub include: Option<Vec<Include>>,
90 /// Additional JSON properties, sent as extra multipart text fields
91 /// (strings verbatim, other JSON values serialized).
92 pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
93}
94
95/// The timestamp granularities to populate for a transcription.
96#[derive(Debug, Serialize, Clone, Copy)]
97#[serde(rename_all = "snake_case")]
98pub enum TimestampGranularity {
99 Word,
100 Segment,
101}
102
103/// Additional information to include in the transcription response.
104#[derive(Debug, Serialize, Clone, Copy)]
105#[serde(rename_all = "snake_case")]
106pub enum Include {
107 Logprobs,
108}
109
110/// Controls how the audio is cut into chunks.
111#[derive(Debug, Clone)]
112pub enum ChunkingStrategy {
113 /// The server normalizes loudness and then uses voice activity detection
114 /// (VAD) to choose boundaries.
115 Auto,
116 /// Tweak VAD detection parameters manually.
117 ServerVad(ServerVadConfig),
118}
119
120/// Manual VAD detection parameters, sent as the `server_vad` chunking
121/// strategy.
122#[derive(Debug, Clone, Default)]
123pub struct ServerVadConfig {
124 /// Amount of audio to include before the VAD detected speech (in
125 /// milliseconds).
126 pub prefix_padding_ms: Option<u32>,
127 /// Duration of silence to detect speech stop (in milliseconds).
128 pub silence_duration_ms: Option<u32>,
129 /// Sensitivity threshold (0.0 to 1.0) for voice activity detection.
130 pub threshold: Option<f32>,
131}
132
133/// The typed transcription response: `json` yields a plain
134/// [`Transcription`], `verbose_json` yields a
135/// [`TranscriptionVerbose`].
136#[derive(Debug, Deserialize, Serialize, Clone)]
137#[serde(untagged)]
138pub enum TranscriptionResponse {
139 /// The `verbose_json` response shape (requires `duration` and
140 /// `language`).
141 Verbose(TranscriptionVerbose),
142 /// The `json` response shape.
143 Plain(Transcription),
144}
145
146/// Represents a transcription response returned by the model.
147#[derive(Debug, Deserialize, Serialize, Clone)]
148pub struct Transcription {
149 /// The transcribed text.
150 pub text: String,
151 /// The languages detected in the audio.
152 ///
153 /// Returned by `gpt-transcribe`. An empty array indicates that no
154 /// language could be reliably detected.
155 pub languages: Option<Vec<TranscriptionLanguage>>,
156 /// The log probabilities of the tokens in the transcription.
157 ///
158 /// Only returned with the models `gpt-4o-transcribe` and
159 /// `gpt-4o-mini-transcribe` if `logprobs` is added to the `include`
160 /// array.
161 pub logprobs: Option<Vec<TranscriptionLogprob>>,
162 /// Usage statistics for the request.
163 pub usage: Option<TranscriptionUsage>,
164}
165
166/// A language detected in transcribed audio.
167#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
168pub struct TranscriptionLanguage {
169 /// The code of a language detected in the audio.
170 pub code: String,
171}
172
173/// The log probability of a token in the transcription.
174#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
175pub struct TranscriptionLogprob {
176 /// The token in the transcription.
177 pub token: Option<String>,
178 /// The bytes of the token.
179 pub bytes: Option<Vec<f32>>,
180 /// The log probability of the token.
181 pub logprob: Option<f32>,
182}
183
184/// Usage statistics for a transcription request. Billed either by token
185/// usage or by audio input duration, discriminated by `type`.
186#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
187#[serde(tag = "type", rename_all = "snake_case")]
188pub enum TranscriptionUsage {
189 /// Usage statistics for models billed by token usage.
190 Tokens {
191 /// Number of input tokens billed for this request.
192 input_tokens: u64,
193 /// Number of output tokens generated.
194 output_tokens: u64,
195 /// Total number of tokens used (input + output).
196 total_tokens: u64,
197 /// Details about the input tokens billed for this request.
198 input_token_details: Option<UsageTokensInputTokenDetails>,
199 },
200 /// Usage statistics for models billed by audio input duration.
201 Duration {
202 /// Duration of the input audio in seconds.
203 seconds: f64,
204 },
205}
206
207/// Details about the input tokens billed for a request.
208#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
209pub struct UsageTokensInputTokenDetails {
210 /// Number of audio tokens billed for this request.
211 pub audio_tokens: Option<u64>,
212 /// Number of text tokens billed for this request.
213 pub text_tokens: Option<u64>,
214}
215
216/// Represents a verbose json transcription response.
217#[derive(Debug, Deserialize, Serialize, Clone)]
218pub struct TranscriptionVerbose {
219 /// The duration of the input audio.
220 pub duration: f64,
221 /// The language of the input audio.
222 pub language: String,
223 /// The transcribed text.
224 pub text: String,
225 /// Segments of the transcribed text and their corresponding details.
226 pub segments: Option<Vec<TranscriptionSegment>>,
227 /// Usage statistics for models billed by audio input duration.
228 pub usage: Option<TranscriptionVerboseUsage>,
229 /// Extracted words and their corresponding timestamps.
230 pub words: Option<Vec<TranscriptionWord>>,
231}
232
233/// Usage statistics for models billed by audio input duration.
234#[derive(Debug, Deserialize, Serialize, Clone)]
235pub struct TranscriptionVerboseUsage {
236 /// Duration of the input audio in seconds.
237 pub seconds: f64,
238}
239
240/// A segment of the transcribed text and its corresponding details.
241#[derive(Debug, Deserialize, Serialize, Clone)]
242pub struct TranscriptionSegment {
243 /// Unique identifier of the segment.
244 pub id: u64,
245 /// Average logprob of the segment.
246 ///
247 /// If the value is lower than -1, consider the logprobs failed.
248 pub avg_logprob: f64,
249 /// Compression ratio of the segment.
250 ///
251 /// If the value is greater than 2.4, consider the compression failed.
252 pub compression_ratio: f64,
253 /// End time of the segment in seconds.
254 pub end: f64,
255 /// Probability of no speech in the segment.
256 pub no_speech_prob: f64,
257 /// Seek offset of the segment.
258 pub seek: u64,
259 /// Start time of the segment in seconds.
260 pub start: f64,
261 /// Temperature parameter used for generating the segment.
262 pub temperature: f64,
263 /// Text content of the segment.
264 pub text: String,
265 /// Array of token IDs for the text content.
266 pub tokens: Vec<u64>,
267}
268
269/// An extracted word and its corresponding timestamp.
270#[derive(Debug, Deserialize, Serialize, Clone)]
271pub struct TranscriptionWord {
272 /// End time of the word in seconds.
273 pub end: f64,
274 /// Start time of the word in seconds.
275 pub start: f64,
276 /// The text content of the word.
277 pub word: String,
278}
279
280crate::impl_from_str!(TranscriptionResponse);
281
282impl Post for TranscriptionRequest {
283 #[inline]
284 fn is_streaming(&self) -> bool {
285 false
286 }
287
288 /// Builds the URL for the request.
289 ///
290 /// `base_url` should be like <https://api.openai.com/v1>
291 fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
292 let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
293 url.path_segments_mut()
294 .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
295 .push("audio")
296 .push("transcriptions");
297
298 Ok(url.to_string())
299 }
300}
301
302impl PostNoStream for TranscriptionRequest {
303 type Response = TranscriptionResponse;
304
305 /// Sends a transcription POST request using multipart/form-data format,
306 /// following the field layout of the official SDK.
307 async fn get_response_string(
308 &self,
309 client: &reqwest::Client,
310 base_url: &str,
311 options: &RequestOptions,
312 ) -> Result<String, OapiError> {
313 if !self.file.exists() {
314 return Err(OapiError::FileNotFoundError(self.file.clone()));
315 }
316
317 let content = tokio::fs::read(&self.file).await?;
318 let file_name = self
319 .file
320 .file_name()
321 .and_then(|name| name.to_str())
322 .ok_or_else(|| OapiError::ResponseError("Invalid file name".to_string()))?
323 .to_string();
324
325 let file_part = reqwest::multipart::Part::bytes(content).file_name(file_name);
326 let mut form = reqwest::multipart::Form::new().part("file", file_part);
327
328 form = form.text("model", self.model.clone());
329
330 if let Some(language) = &self.language {
331 form = form.text("language", language.clone());
332 }
333 if let Some(prompt) = &self.prompt {
334 form = form.text("prompt", prompt.clone());
335 }
336 if let Some(response_format) = self.response_format {
337 let literal = crate::audio::enum_to_literal(&response_format)?;
338 form = form.text("response_format", literal);
339 }
340 if let Some(temperature) = self.temperature {
341 form = form.text("temperature", temperature.to_string());
342 }
343 // List parameters are sent as repeated `name[]` parts, matching the
344 // official SDK.
345 if let Some(granularities) = &self.timestamp_granularities {
346 for granularity in granularities {
347 let literal = crate::audio::enum_to_literal(granularity)?;
348 form = form.text("timestamp_granularities[]", literal);
349 }
350 }
351 if let Some(include) = &self.include {
352 for item in include {
353 let literal = crate::audio::enum_to_literal(item)?;
354 form = form.text("include[]", literal);
355 }
356 }
357 if let Some(chunking_strategy) = &self.chunking_strategy {
358 let value = match chunking_strategy {
359 ChunkingStrategy::Auto => "auto".to_string(),
360 ChunkingStrategy::ServerVad(config) => {
361 let mut map = serde_json::Map::new();
362 map.insert("type".to_string(), "server_vad".into());
363 if let Some(v) = config.prefix_padding_ms {
364 map.insert("prefix_padding_ms".to_string(), v.into());
365 }
366 if let Some(v) = config.silence_duration_ms {
367 map.insert("silence_duration_ms".to_string(), v.into());
368 }
369 if let Some(v) = config.threshold {
370 map.insert("threshold".to_string(), v.into());
371 }
372 serde_json::to_string(&map).map_err(|e| {
373 OapiError::ResponseError(format!(
374 "Failed to serialize chunking_strategy: {e}"
375 ))
376 })?
377 }
378 };
379 form = form.text("chunking_strategy", value);
380 }
381
382 form = crate::rest::post::append_extra_body_map(form, &self.extra_body_map);
383
384 let url = self.build_url(base_url)?;
385 crate::rest::post::post_multipart_json(client, url, form, options).await
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_build_url() {
395 let request = TranscriptionRequest::default();
396 let url = request.build_url("https://api.openai.com/v1/").unwrap();
397 assert_eq!(url, "https://api.openai.com/v1/audio/transcriptions");
398 }
399
400 /// Enum literals serialize to their official wire values.
401 #[test]
402 fn enum_literals() {
403 assert_eq!(
404 crate::audio::enum_to_literal(&AudioResponseFormat::VerboseJson).unwrap(),
405 "verbose_json"
406 );
407 assert_eq!(
408 crate::audio::enum_to_literal(&TimestampGranularity::Word).unwrap(),
409 "word"
410 );
411 assert_eq!(
412 crate::audio::enum_to_literal(&Include::Logprobs).unwrap(),
413 "logprobs"
414 );
415 }
416
417 /// Deserializes a `json` response (plain transcription shape).
418 ///
419 /// No accessible provider implements this endpoint, so this fixture is
420 /// NOT captured from a live response. The structure follows the schema
421 /// of openai-python `types/audio/transcription.py`; the values are
422 /// constructed for the test.
423 #[test]
424 fn parse_plain_response() {
425 let content = r#"{
426 "text": "The quick brown fox jumped over the lazy dog."
427 }"#;
428
429 let response: TranscriptionResponse = content.parse().unwrap();
430 let TranscriptionResponse::Plain(transcription) = response else {
431 panic!("expected plain transcription");
432 };
433 assert_eq!(
434 transcription.text,
435 "The quick brown fox jumped over the lazy dog."
436 );
437 assert_eq!(transcription.languages, None);
438 assert_eq!(transcription.logprobs, None);
439 assert_eq!(transcription.usage, None);
440 }
441
442 /// Deserializes a `verbose_json` response with segments, words, and
443 /// duration-billed usage.
444 ///
445 /// No accessible provider implements this endpoint, so this fixture is
446 /// NOT captured from a live response. The structure follows the schema
447 /// of openai-python `types/audio/transcription_verbose.py` +
448 /// `transcription_segment.py` + `transcription_word.py`; the values are
449 /// constructed for the test.
450 #[test]
451 fn parse_verbose_response() {
452 let content = r#"{
453 "duration": 8.47,
454 "language": "english",
455 "text": "The quick brown fox jumped over the lazy dog.",
456 "segments": [
457 {
458 "id": 0,
459 "avg_logprob": -0.2365,
460 "compression_ratio": 1.7174,
461 "end": 3.48,
462 "no_speech_prob": 0.01485,
463 "seek": 0,
464 "start": 0.0,
465 "temperature": 0.0,
466 "text": " The quick brown fox jumped over the lazy dog.",
467 "tokens": [464, 2069, 7586, 21831, 18045, 625, 262, 16931, 3290, 13]
468 }
469 ],
470 "words": [
471 {
472 "end": 0.36,
473 "start": 0.06,
474 "word": "The"
475 }
476 ],
477 "usage": {
478 "type": "duration",
479 "seconds": 8.47
480 }
481 }"#;
482
483 let response: TranscriptionResponse = content.parse().unwrap();
484 let TranscriptionResponse::Verbose(verbose) = response else {
485 panic!("expected verbose transcription");
486 };
487 assert_eq!(verbose.duration, 8.47);
488 assert_eq!(verbose.language, "english");
489 assert_eq!(
490 verbose.text,
491 "The quick brown fox jumped over the lazy dog."
492 );
493 let segments = verbose.segments.unwrap();
494 assert_eq!(segments.len(), 1);
495 assert_eq!(segments[0].id, 0);
496 assert_eq!(segments[0].start, 0.0);
497 assert_eq!(segments[0].end, 3.48);
498 assert_eq!(segments[0].tokens.len(), 10);
499 let words = verbose.words.unwrap();
500 assert_eq!(words[0].word, "The");
501 assert_eq!(words[0].start, 0.06);
502 let usage = verbose.usage.unwrap();
503 assert_eq!(usage.seconds, 8.47);
504 }
505
506 /// Deserializes a token-billed usage object (discriminated by `type`).
507 ///
508 /// No accessible provider implements this endpoint, so this fixture is
509 /// NOT captured from a live response. The structure follows the schema
510 /// of openai-python `types/audio/transcription.py` (`UsageTokens`);
511 /// the values are constructed for the test.
512 #[test]
513 fn parse_tokens_usage() {
514 let content = r#"{
515 "text": "Hello.",
516 "usage": {
517 "type": "tokens",
518 "input_tokens": 76,
519 "output_tokens": 13,
520 "total_tokens": 89,
521 "input_token_details": {
522 "audio_tokens": 76,
523 "text_tokens": 0
524 }
525 }
526 }"#;
527
528 let response: TranscriptionResponse = content.parse().unwrap();
529 let TranscriptionResponse::Plain(transcription) = response else {
530 panic!("expected plain transcription");
531 };
532 let TranscriptionUsage::Tokens {
533 input_tokens,
534 output_tokens,
535 total_tokens,
536 input_token_details,
537 } = transcription.usage.unwrap()
538 else {
539 panic!("expected tokens usage");
540 };
541 assert_eq!(input_tokens, 76);
542 assert_eq!(output_tokens, 13);
543 assert_eq!(total_tokens, 89);
544 let details = input_token_details.unwrap();
545 assert_eq!(details.audio_tokens, Some(76));
546 assert_eq!(details.text_tokens, Some(0));
547 }
548}