Skip to main content

openai_interface/audio/
translations.rs

1//! Translates audio into English.
2//!
3//! Endpoint: `POST /audio/translations` (multipart/form-data request).
4//!
5//! Response shapes depend on `response_format`, same as transcriptions:
6//!
7//! - `json` and `verbose_json` deserialize into the typed
8//!   [`TranslationResponse`] via `get_response`.
9//! - `text`, `srt`, and `vtt` return plain text; use
10//!   `get_response_string` for those.
11//!
12//! > ![warn] This module is untested!
13//! > No OpenAI-compatible provider accessible to this project implements
14//! > this endpoint, and no OpenAI API key was available for testing. If you
15//! > encounter any issues, please report them on the repository.
16
17use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20use url::Url;
21
22use crate::{
23    audio::{AudioResponseFormat, transcriptions::TranscriptionVerbose},
24    errors::OapiError,
25    rest::RequestOptions,
26    rest::post::{Post, PostNoStream},
27};
28
29/// Translates audio into English. Only `whisper-1` (which is powered by the
30/// open source Whisper V2 model) is currently available.
31#[derive(Debug, Serialize, Default, Clone)]
32pub struct TranslationRequest {
33    /// The audio file (as a path) to translate, in one of these formats:
34    /// flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm. The request must
35    /// include enough format metadata for the file to be identified; an
36    /// extension-bearing filename satisfies this.
37    #[serde(skip_serializing)]
38    pub file: PathBuf,
39    /// ID of the model to use. Only `whisper-1` (which is powered by the
40    /// open source Whisper V2 model) is currently available.
41    pub model: String,
42    /// An optional text to guide the model's style or continue a previous
43    /// audio segment. The prompt should be in English.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub prompt: Option<String>,
46    /// The format of the output, in one of these options: `json`, `text`,
47    /// `srt`, `verbose_json`, or `vtt`.
48    ///
49    /// With `json` / `verbose_json`, use `get_response`; with `text` /
50    /// `srt` / `vtt`, use `get_response_string`.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub response_format: Option<AudioResponseFormat>,
53    /// The sampling temperature, between 0 and 1. Higher values like 0.8
54    /// will make the output more random, while lower values like 0.2 will
55    /// make it more focused and deterministic.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub temperature: Option<f32>,
58    /// Additional JSON properties, sent as extra multipart text fields
59    /// (strings verbatim, other JSON values serialized).
60    pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
61}
62
63/// The typed translation response: `json` yields a plain
64/// [`Translation`], `verbose_json` yields a [`TranscriptionVerbose`].
65#[derive(Debug, Deserialize, Serialize, Clone)]
66#[serde(untagged)]
67pub enum TranslationResponse {
68    /// The `verbose_json` response shape (requires `duration` and
69    /// `language`).
70    Verbose(TranscriptionVerbose),
71    /// The `json` response shape.
72    Plain(Translation),
73}
74
75/// Represents a translation response returned by the model.
76#[derive(Debug, Deserialize, Serialize, Clone)]
77pub struct Translation {
78    /// The translated text.
79    pub text: String,
80}
81
82crate::impl_from_str!(TranslationResponse);
83
84impl Post for TranslationRequest {
85    #[inline]
86    fn is_streaming(&self) -> bool {
87        false
88    }
89
90    /// Builds the URL for the request.
91    ///
92    /// `base_url` should be like <https://api.openai.com/v1>
93    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
94        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
95        url.path_segments_mut()
96            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
97            .push("audio")
98            .push("translations");
99
100        Ok(url.to_string())
101    }
102}
103
104impl PostNoStream for TranslationRequest {
105    type Response = TranslationResponse;
106
107    /// Sends a translation POST request using multipart/form-data format,
108    /// following the field layout of the official SDK.
109    async fn get_response_string(
110        &self,
111        client: &reqwest::Client,
112        base_url: &str,
113        options: &RequestOptions,
114    ) -> Result<String, OapiError> {
115        if !self.file.exists() {
116            return Err(OapiError::FileNotFoundError(self.file.clone()));
117        }
118
119        let content = tokio::fs::read(&self.file).await?;
120        let file_name = self
121            .file
122            .file_name()
123            .and_then(|name| name.to_str())
124            .ok_or_else(|| OapiError::ResponseError("Invalid file name".to_string()))?
125            .to_string();
126
127        let file_part = reqwest::multipart::Part::bytes(content).file_name(file_name);
128        let mut form = reqwest::multipart::Form::new().part("file", file_part);
129
130        form = form.text("model", self.model.clone());
131
132        if let Some(prompt) = &self.prompt {
133            form = form.text("prompt", prompt.clone());
134        }
135        if let Some(response_format) = self.response_format {
136            let literal = crate::audio::enum_to_literal(&response_format)?;
137            form = form.text("response_format", literal);
138        }
139        if let Some(temperature) = self.temperature {
140            form = form.text("temperature", temperature.to_string());
141        }
142
143        form = crate::rest::post::append_extra_body_map(form, &self.extra_body_map);
144
145        let url = self.build_url(base_url)?;
146        crate::rest::post::post_multipart_json(client, url, form, options).await
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_build_url() {
156        let request = TranslationRequest::default();
157        let url = request.build_url("https://api.openai.com/v1/").unwrap();
158        assert_eq!(url, "https://api.openai.com/v1/audio/translations");
159    }
160
161    /// Deserializes a `json` translation response.
162    ///
163    /// No accessible provider implements this endpoint, so this fixture is
164    /// NOT captured from a live response. The structure follows the schema
165    /// of openai-python `types/audio/translation.py`; the values are
166    /// constructed for the test.
167    #[test]
168    fn parse_plain_response() {
169        let content = r#"{
170            "text": "The quick brown fox jumped over the lazy dog."
171        }"#;
172
173        let response: TranslationResponse = content.parse().unwrap();
174        let TranslationResponse::Plain(translation) = response else {
175            panic!("expected plain translation");
176        };
177        assert_eq!(
178            translation.text,
179            "The quick brown fox jumped over the lazy dog."
180        );
181    }
182}