use bytes::Bytes;
use crate::audio_generation::{
self, AudioGenerationError, AudioGenerationRequest, AudioGenerationResponse,
};
use crate::client::{Client, Provider};
use crate::http_client::{self, HttpClientExt};
use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
pub(crate) trait RawAudioGenerationProvider: Provider {
const AUDIO_GENERATION_PATH: &'static str;
const EXPLICIT_JSON_CONTENT_TYPE: bool = false;
fn audio_generation_request_builder<H>(
client: &Client<Self, H>,
_model: &str,
) -> Result<http_client::Builder, AudioGenerationError>
where
H: HttpClientExt,
{
let builder = client.post(Self::AUDIO_GENERATION_PATH)?;
Ok(if Self::EXPLICIT_JSON_CONTENT_TYPE {
builder.header("Content-Type", "application/json")
} else {
builder
})
}
fn audio_generation_request_body(
model: &str,
request: AudioGenerationRequest,
) -> Result<serde_json::Value, AudioGenerationError> {
let mut body = serde_json::json!({
"model": model,
"input": request.text,
"voice": request.voice,
"speed": request.speed,
});
if let Some(additional_params) = request.additional_params {
crate::json_utils::merge_inplace(&mut body, additional_params);
}
Ok(body)
}
}
#[doc(hidden)]
#[derive(Clone)]
pub struct GenericAudioGenerationModel<Ext, H = reqwest::Client> {
client: Client<Ext, H>,
pub model: String,
}
impl<Ext, H> GenericAudioGenerationModel<Ext, H> {
pub fn new(client: Client<Ext, H>, model: impl Into<String>) -> Self {
Self {
client,
model: model.into(),
}
}
}
impl<Ext, H> audio_generation::AudioGenerationModel for GenericAudioGenerationModel<Ext, H>
where
Ext: RawAudioGenerationProvider + Clone + WasmCompatSend + WasmCompatSync + 'static,
H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
{
type Response = Bytes;
type Client = Client<Ext, H>;
fn make(client: &Self::Client, model: impl Into<String>) -> Self {
Self::new(client.clone(), model)
}
async fn audio_generation(
&self,
request: AudioGenerationRequest,
) -> Result<AudioGenerationResponse<Self::Response>, AudioGenerationError> {
let builder = Ext::audio_generation_request_builder(&self.client, &self.model)?;
let body = Ext::audio_generation_request_body(&self.model, request)?;
send_audio_generation(&self.client, builder, body).await
}
}
pub(crate) async fn send_audio_generation<C>(
client: &C,
builder: http_client::Builder,
body: serde_json::Value,
) -> Result<AudioGenerationResponse<Bytes>, AudioGenerationError>
where
C: HttpClientExt,
{
let body = serde_json::to_vec(&body)?;
let req = builder
.body(body)
.map_err(|e| AudioGenerationError::HttpError(e.into()))?;
let response = client.send::<_, Bytes>(req).await?;
let (parts, body) = response.into_parts();
let status = parts.status;
let bytes: Bytes = body.await?;
if !status.is_success() {
return Err(AudioGenerationError::from_http_response(
status,
String::from_utf8_lossy(&bytes),
)
.with_response_headers(Some(Box::new(parts.headers))));
}
Ok(AudioGenerationResponse {
audio: bytes.to_vec(),
response: bytes,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct DefaultAudioExt;
impl Provider for DefaultAudioExt {
type Builder = crate::providers::openai::OpenAICompletionsExtBuilder;
const VERIFY_PATH: &'static str = "/models";
}
impl RawAudioGenerationProvider for DefaultAudioExt {
const AUDIO_GENERATION_PATH: &'static str = "/audio/speech";
}
fn body(additional_params: Option<serde_json::Value>) -> serde_json::Value {
DefaultAudioExt::audio_generation_request_body(
"tts-1",
AudioGenerationRequest {
text: "hello".to_owned(),
voice: "alloy".to_owned(),
speed: 1.0,
additional_params,
},
)
.expect("body should build")
}
#[test]
fn request_body_derives_the_documented_fields() {
let body = body(None);
assert_eq!(body["model"], json!("tts-1"));
assert_eq!(body["input"], json!("hello"));
assert_eq!(body["voice"], json!("alloy"));
assert_eq!(body["speed"], json!(1.0));
}
#[test]
fn request_body_merges_additional_params_last() {
let body = body(Some(
json!({ "response_format": "wav", "instructions": "Speak slowly." }),
));
assert_eq!(body["response_format"], json!("wav"));
assert_eq!(body["instructions"], json!("Speak slowly."));
}
#[test]
fn request_body_lets_additional_params_override_derived_keys() {
let body = body(Some(
json!({ "voice": "nova", "speed": 0.5, "input": "other" }),
));
assert_eq!(body["voice"], json!("nova"));
assert_eq!(body["speed"], json!(0.5));
assert_eq!(body["input"], json!("other"));
}
#[test]
fn request_body_ignores_non_object_additional_params() {
assert_eq!(body(Some(json!("not-an-object"))), body(None));
assert_eq!(body(Some(json!(null))), body(None));
}
}
#[cfg(test)]
mod header_preservation_tests {
use super::*;
use crate::test_utils::RecordingHttpClient;
#[tokio::test]
async fn non_success_response_preserves_headers() {
let mut headers = http::HeaderMap::new();
headers.insert(http::header::RETRY_AFTER, "20".parse().expect("value"));
let client = RecordingHttpClient::with_error_response_headers(
http::StatusCode::TOO_MANY_REQUESTS,
r#"{"error":"slow down"}"#,
headers,
);
let error = send_audio_generation(
&client,
http_client::Request::builder()
.method(http::Method::POST)
.uri("https://example.test/v1/audio/speech"),
serde_json::json!({}),
)
.await
.err()
.expect("a 429 should fail");
assert_eq!(
error
.provider_response_headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some("20"),
);
assert_eq!(
error.provider_response_status(),
Some(http::StatusCode::TOO_MANY_REQUESTS)
);
}
}