Skip to main content

rig_core/providers/openai/
transcription.rs

1use bytes::Bytes;
2
3use crate::http_client::multipart::Part;
4use crate::http_client::{HttpClientExt, MultipartForm};
5use crate::providers::openai::{Client, client::ApiResponse};
6use crate::transcription;
7use crate::transcription::TranscriptionError;
8use serde::Deserialize;
9
10// ================================================================
11// OpenAI Transcription API
12// ================================================================
13
14pub const WHISPER_1: &str = "whisper-1";
15
16#[derive(Debug, Deserialize)]
17pub struct TranscriptionResponse {
18    pub text: String,
19}
20
21impl TryFrom<TranscriptionResponse>
22    for transcription::TranscriptionResponse<TranscriptionResponse>
23{
24    type Error = TranscriptionError;
25
26    fn try_from(value: TranscriptionResponse) -> Result<Self, Self::Error> {
27        Ok(transcription::TranscriptionResponse {
28            text: value.text.clone(),
29            response: value,
30        })
31    }
32}
33
34#[derive(Clone)]
35pub struct TranscriptionModel<T = reqwest::Client> {
36    client: Client<T>,
37    pub model: String,
38}
39
40impl<T> TranscriptionModel<T> {
41    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
42        Self {
43            client,
44            model: model.into(),
45        }
46    }
47}
48
49impl<T> transcription::TranscriptionModel for TranscriptionModel<T>
50where
51    T: HttpClientExt + Clone + std::fmt::Debug + Default + Send + 'static,
52{
53    type Response = TranscriptionResponse;
54
55    type Client = Client<T>;
56
57    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
58        Self::new(client.clone(), model)
59    }
60
61    async fn transcription(
62        &self,
63        request: transcription::TranscriptionRequest,
64    ) -> Result<
65        transcription::TranscriptionResponse<Self::Response>,
66        transcription::TranscriptionError,
67    > {
68        let data = request.data;
69
70        let mut body = MultipartForm::new()
71            .text("model", self.model.clone())
72            .part(Part::bytes("file", data).filename(request.filename.clone()));
73
74        if let Some(language) = request.language {
75            body = body.text("language", language);
76        }
77
78        if let Some(prompt) = request.prompt {
79            body = body.text("prompt", prompt.clone());
80        }
81
82        if let Some(ref temperature) = request.temperature {
83            body = body.text("temperature", temperature.to_string());
84        }
85
86        if let Some(ref additional_params) = request.additional_params {
87            let params = additional_params.as_object().ok_or_else(|| {
88                TranscriptionError::RequestError(Box::new(std::io::Error::new(
89                    std::io::ErrorKind::InvalidInput,
90                    "additional transcription parameters must be a JSON object",
91                )))
92            })?;
93
94            for (key, value) in params {
95                body = body.text(key.to_owned(), value.to_string());
96            }
97        }
98
99        let req = self
100            .client
101            .post("/audio/transcriptions")?
102            .body(body)
103            .map_err(|e| TranscriptionError::HttpError(e.into()))?;
104
105        let response = self.client.send_multipart::<Bytes>(req).await?;
106
107        let status = response.status();
108        let response_body = response.into_body().into_future().await?.to_vec();
109        if status.is_success() {
110            match serde_json::from_slice::<ApiResponse<TranscriptionResponse>>(&response_body)? {
111                ApiResponse::Ok(response) => response.try_into(),
112                ApiResponse::Err(api_error_response) => {
113                    tracing::warn!(message = %api_error_response.message, "provider returned an error response");
114                    Err(TranscriptionError::from_http_response(
115                        status,
116                        String::from_utf8_lossy(&response_body).into_owned(),
117                    ))
118                }
119            }
120        } else {
121            let str = String::from_utf8_lossy(&response_body).to_string();
122            Err(TranscriptionError::from_http_response(status, str))
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::client::transcription::TranscriptionClient;
131    use crate::test_utils::RecordingHttpClient;
132    use crate::transcription::TranscriptionModel as _;
133
134    #[tokio::test]
135    async fn transcription_http_non_success_preserves_status_and_body() {
136        let body = r#"{"error":{"message":"bad audio","type":"invalid_request_error"}}"#;
137        let http_client =
138            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
139        let client = Client::builder()
140            .api_key("test-key")
141            .http_client(http_client)
142            .build()
143            .expect("build client");
144        let model = client.transcription_model(WHISPER_1);
145
146        let error = match model
147            .transcription_request()
148            .data(vec![0u8; 16])
149            .send()
150            .await
151        {
152            Err(error) => error,
153            Ok(_) => panic!("transcription should fail with non-success status"),
154        };
155
156        assert!(matches!(error, TranscriptionError::HttpError(_)));
157        assert_eq!(
158            error.provider_response_status(),
159            Some(http::StatusCode::BAD_REQUEST)
160        );
161        assert_eq!(error.provider_response_body(), Some(body));
162    }
163}