openai-interface 0.14.0

A low-level Rust interface for the OpenAI API
Documentation
//! Translates audio into English.
//!
//! Endpoint: `POST /audio/translations` (multipart/form-data request).
//!
//! Response shapes depend on `response_format`, same as transcriptions:
//!
//! - `json` and `verbose_json` deserialize into the typed
//!   [`TranslationResponse`] via `get_response`.
//! - `text`, `srt`, and `vtt` return plain text; use
//!   `get_response_string` for those.
//!
//! > ![warn] This module is untested!
//! > No OpenAI-compatible provider accessible to this project implements
//! > this endpoint, and no OpenAI API key was available for testing. If you
//! > encounter any issues, please report them on the repository.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use url::Url;

use crate::{
    audio::{AudioResponseFormat, transcriptions::TranscriptionVerbose},
    errors::OapiError,
    rest::RequestOptions,
    rest::post::{Post, PostNoStream},
};

/// Translates audio into English. Only `whisper-1` (which is powered by the
/// open source Whisper V2 model) is currently available.
#[derive(Debug, Serialize, Default, Clone)]
pub struct TranslationRequest {
    /// The audio file (as a path) to translate, in one of these formats:
    /// flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm. The request must
    /// include enough format metadata for the file to be identified; an
    /// extension-bearing filename satisfies this.
    #[serde(skip_serializing)]
    pub file: PathBuf,
    /// ID of the model to use. Only `whisper-1` (which is powered by the
    /// open source Whisper V2 model) is currently available.
    pub model: String,
    /// An optional text to guide the model's style or continue a previous
    /// audio segment. The prompt should be in English.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,
    /// The format of the output, in one of these options: `json`, `text`,
    /// `srt`, `verbose_json`, or `vtt`.
    ///
    /// With `json` / `verbose_json`, use `get_response`; with `text` /
    /// `srt` / `vtt`, use `get_response_string`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub response_format: Option<AudioResponseFormat>,
    /// The sampling temperature, between 0 and 1. Higher values like 0.8
    /// will make the output more random, while lower values like 0.2 will
    /// make it more focused and deterministic.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub temperature: Option<f32>,
    /// Additional JSON properties, sent as extra multipart text fields
    /// (strings verbatim, other JSON values serialized).
    pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
}

/// The typed translation response: `json` yields a plain
/// [`Translation`], `verbose_json` yields a [`TranscriptionVerbose`].
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum TranslationResponse {
    /// The `verbose_json` response shape (requires `duration` and
    /// `language`).
    Verbose(TranscriptionVerbose),
    /// The `json` response shape.
    Plain(Translation),
}

/// Represents a translation response returned by the model.
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Translation {
    /// The translated text.
    pub text: String,
}

crate::impl_from_str!(TranslationResponse);

impl Post for TranslationRequest {
    #[inline]
    fn is_streaming(&self) -> bool {
        false
    }

    /// Builds the URL for the request.
    ///
    /// `base_url` should be like <https://api.openai.com/v1>
    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
        url.path_segments_mut()
            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
            .push("audio")
            .push("translations");

        Ok(url.to_string())
    }
}

impl PostNoStream for TranslationRequest {
    type Response = TranslationResponse;

    /// Sends a translation POST request using multipart/form-data format,
    /// following the field layout of the official SDK.
    async fn get_response_string(
        &self,
        client: &reqwest::Client,
        base_url: &str,
        options: &RequestOptions,
    ) -> Result<String, OapiError> {
        if !self.file.exists() {
            return Err(OapiError::FileNotFoundError(self.file.clone()));
        }

        let content = tokio::fs::read(&self.file).await?;
        let file_name = self
            .file
            .file_name()
            .and_then(|name| name.to_str())
            .ok_or_else(|| OapiError::ResponseError("Invalid file name".to_string()))?
            .to_string();

        let file_part = reqwest::multipart::Part::bytes(content).file_name(file_name);
        let mut form = reqwest::multipart::Form::new().part("file", file_part);

        form = form.text("model", self.model.clone());

        if let Some(prompt) = &self.prompt {
            form = form.text("prompt", prompt.clone());
        }
        if let Some(response_format) = self.response_format {
            let literal = crate::audio::enum_to_literal(&response_format)?;
            form = form.text("response_format", literal);
        }
        if let Some(temperature) = self.temperature {
            form = form.text("temperature", temperature.to_string());
        }

        form = crate::rest::post::append_extra_body_map(form, &self.extra_body_map);

        let url = self.build_url(base_url)?;
        crate::rest::post::post_multipart_json(client, url, form, options).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_url() {
        let request = TranslationRequest::default();
        let url = request.build_url("https://api.openai.com/v1/").unwrap();
        assert_eq!(url, "https://api.openai.com/v1/audio/translations");
    }

    /// Deserializes a `json` translation response.
    ///
    /// No accessible provider implements this endpoint, so this fixture is
    /// NOT captured from a live response. The structure follows the schema
    /// of openai-python `types/audio/translation.py`; the values are
    /// constructed for the test.
    #[test]
    fn parse_plain_response() {
        let content = r#"{
            "text": "The quick brown fox jumped over the lazy dog."
        }"#;

        let response: TranslationResponse = content.parse().unwrap();
        let TranslationResponse::Plain(translation) = response else {
            panic!("expected plain translation");
        };
        assert_eq!(
            translation.text,
            "The quick brown fox jumped over the lazy dog."
        );
    }
}