use bytes::Bytes;
use crate::http_client::multipart::Part;
use crate::http_client::{HttpClientExt, MultipartForm};
use crate::providers::openai::{Client, client::ApiResponse};
use crate::transcription;
use crate::transcription::TranscriptionError;
use serde::Deserialize;
pub const WHISPER_1: &str = "whisper-1";
#[derive(Debug, Deserialize)]
pub struct TranscriptionResponse {
pub text: String,
}
impl TryFrom<TranscriptionResponse>
for transcription::TranscriptionResponse<TranscriptionResponse>
{
type Error = TranscriptionError;
fn try_from(value: TranscriptionResponse) -> Result<Self, Self::Error> {
Ok(transcription::TranscriptionResponse {
text: value.text.clone(),
response: value,
})
}
}
#[derive(Clone)]
pub struct TranscriptionModel<T = reqwest::Client> {
client: Client<T>,
pub model: String,
}
impl<T> TranscriptionModel<T> {
pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
Self {
client,
model: model.into(),
}
}
}
impl<T> transcription::TranscriptionModel for TranscriptionModel<T>
where
T: HttpClientExt + Clone + std::fmt::Debug + Default + Send + 'static,
{
type Response = TranscriptionResponse;
type Client = Client<T>;
fn make(client: &Self::Client, model: impl Into<String>) -> Self {
Self::new(client.clone(), model)
}
async fn transcription(
&self,
request: transcription::TranscriptionRequest,
) -> Result<
transcription::TranscriptionResponse<Self::Response>,
transcription::TranscriptionError,
> {
let data = request.data;
let mut body = MultipartForm::new()
.text("model", self.model.clone())
.part(Part::bytes("file", data).filename(request.filename.clone()));
if let Some(language) = request.language {
body = body.text("language", language);
}
if let Some(prompt) = request.prompt {
body = body.text("prompt", prompt.clone());
}
if let Some(ref temperature) = request.temperature {
body = body.text("temperature", temperature.to_string());
}
if let Some(ref additional_params) = request.additional_params {
let params = additional_params.as_object().ok_or_else(|| {
TranscriptionError::RequestError(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"additional transcription parameters must be a JSON object",
)))
})?;
for (key, value) in params {
body = body.text(key.to_owned(), value.to_string());
}
}
let req = self
.client
.post("/audio/transcriptions")?
.body(body)
.map_err(|e| TranscriptionError::HttpError(e.into()))?;
let response = self.client.send_multipart::<Bytes>(req).await?;
let status = response.status();
let response_body = response.into_body().into_future().await?.to_vec();
if status.is_success() {
match serde_json::from_slice::<ApiResponse<TranscriptionResponse>>(&response_body)? {
ApiResponse::Ok(response) => response.try_into(),
ApiResponse::Err(api_error_response) => {
tracing::warn!(message = %api_error_response.message, "provider returned an error response");
Err(TranscriptionError::from_http_response(
status,
String::from_utf8_lossy(&response_body).into_owned(),
))
}
}
} else {
let str = String::from_utf8_lossy(&response_body).to_string();
Err(TranscriptionError::from_http_response(status, str))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::transcription::TranscriptionClient;
use crate::test_utils::RecordingHttpClient;
use crate::transcription::TranscriptionModel as _;
#[tokio::test]
async fn transcription_http_non_success_preserves_status_and_body() {
let body = r#"{"error":{"message":"bad audio","type":"invalid_request_error"}}"#;
let http_client =
RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
let client = Client::builder()
.api_key("test-key")
.http_client(http_client)
.build()
.expect("build client");
let model = client.transcription_model(WHISPER_1);
let error = match model
.transcription_request()
.data(vec![0u8; 16])
.send()
.await
{
Err(error) => error,
Ok(_) => panic!("transcription should fail with non-success status"),
};
assert!(matches!(error, TranscriptionError::HttpError(_)));
assert_eq!(
error.provider_response_status(),
Some(http::StatusCode::BAD_REQUEST)
);
assert_eq!(error.provider_response_body(), Some(body));
}
}