use std::io::Cursor;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use ureq::Agent;
use voxora_traits::AsrError;
use crate::config::MiniMaxConfig;
use crate::params::{MiniMaxParams, MiniMaxParamsApply};
pub const DEFAULT_ENDPOINT: &str = "https://api.minimax.io";
pub const DEFAULT_MODEL: &str = "asr-1.0";
pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
pub const MAX_AUDIO_BYTES: usize = 50 * 1024 * 1024;
#[derive(Clone)]
pub struct MiniMaxClient {
agent: Agent,
endpoint: String,
model: String,
auth_header: String,
}
impl std::fmt::Debug for MiniMaxClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MiniMaxClient")
.field("endpoint", &self.endpoint)
.field("model", &self.model)
.field("auth_header", &"<redacted Bearer token>")
.finish_non_exhaustive()
}
}
impl MiniMaxClient {
pub fn new(config: &MiniMaxConfig) -> Self {
let agent: Agent = Agent::config_builder()
.timeout_global(Some(Duration::from_secs(config.timeout_secs())))
.http_status_as_error(false)
.build()
.into();
Self {
agent,
endpoint: config.endpoint().to_string(),
model: config.model().to_string(),
auth_header: format!("Bearer {}", config.expose_api_key()),
}
}
pub fn transcribe(
&self,
wav_bytes: &[u8],
params: &MiniMaxParams,
) -> Result<AsrResp, AsrError> {
if wav_bytes.is_empty() {
return Err(AsrError::InvalidInput("audio buffer is empty".into()));
}
if wav_bytes.len() > MAX_AUDIO_BYTES {
return Err(AsrError::InvalidInput(format!(
"audio exceeds 50 MB server cap ({} bytes)",
wav_bytes.len()
)));
}
let url = format!("{}/v1/speech_to_text", self.endpoint);
let mut request = self
.agent
.post(&url)
.header("Authorization", &self.auth_header);
if let Some(lang) = params.language_header() {
request = request.header("language", lang);
}
let mp = build_multipart(&self.model, wav_bytes, params);
let resp = match request.send(mp) {
Ok(r) => r,
Err(ureq::Error::StatusCode(status)) => {
return Err(parse_status_only(&url, status));
}
Err(
e @ (ureq::Error::Io(_)
| ureq::Error::ConnectionFailed
| ureq::Error::HostNotFound
| ureq::Error::Timeout(_)),
) => {
return Err(AsrError::network(
url.clone(),
"MiniMax transport failure",
Some(Box::new(e)),
));
}
Err(other) => {
return Err(AsrError::network(
url.clone(),
format!("MiniMax request failure: {other}"),
Some(Box::new(other)),
));
}
};
let status = resp.status().as_u16();
let body = read_response_body(&url, resp)?;
if status != 200 {
return Err(parse_error_body(&url, status, &body));
}
serde_json::from_slice::<AsrResp>(&body)
.map_err(|e| AsrError::Inference(format!("unexpected MiniMax response: {e}")))
}
}
fn build_multipart(
model: &str,
wav_bytes: &[u8],
params: &MiniMaxParams,
) -> ureq::unversioned::multipart::Form<'static> {
use ureq::unversioned::multipart::{Form, Part};
let owned = wav_bytes.to_vec();
let cursor = Cursor::new(owned);
let file_part = Part::owned_reader(cursor)
.file_name("audio.wav")
.mime_str("audio/wav")
.expect("audio/wav is a valid mime type");
let model_static: String = model.to_string();
let model_owned: &'static str = Box::leak(model_static.into_boxed_str());
let resp_format_static: String = "verbose_json".to_string();
let resp_format_owned: &'static str = Box::leak(resp_format_static.into_boxed_str());
let mut form = Form::new()
.text("model", model_owned)
.text("response_format", resp_format_owned);
for (name, value) in params.multipart_fields() {
let name_owned: &'static str = Box::leak(name.to_string().into_boxed_str());
let value_owned: &'static str = Box::leak(value.to_string().into_boxed_str());
form = form.text(name_owned, value_owned);
}
form = form.part("file", file_part);
form
}
fn read_response_body(
url: &str,
mut resp: ureq::http::Response<ureq::Body>,
) -> Result<Vec<u8>, AsrError> {
use std::io::Read;
let mut body = Vec::new();
resp.body_mut()
.as_reader()
.read_to_end(&mut body)
.map_err(|e| {
AsrError::network(
url.to_string(),
"MiniMax body read failure",
Some(Box::new(e)),
)
})?;
Ok(body)
}
fn parse_status_only(url: &str, status: u16) -> AsrError {
match status {
400 => AsrError::InvalidInput(format!("MiniMax HTTP 400 (no body available) at {url}")),
401 | 402 => AsrError::Config(format!(
"MiniMax HTTP {status} auth/balance failure at {url}"
)),
413 => AsrError::InvalidInput(format!("MiniMax HTTP 413 size cap at {url}")),
422 => AsrError::InvalidInput(format!("MiniMax HTTP 422 content rejected at {url}")),
429 => AsrError::network(url.to_string(), format!("MiniMax HTTP 429 at {url}"), None),
s @ 500..=599 => AsrError::Inference(format!("MiniMax HTTP {s} at {url}")),
s => AsrError::Inference(format!("MiniMax HTTP {s} at {url}")),
}
}
fn parse_error_body(url: &str, status: u16, body: &[u8]) -> AsrError {
let parsed: Option<OaiError> = serde_json::from_slice(body).ok();
let message = parsed
.as_ref()
.map(|e| e.error.message.as_str())
.unwrap_or("unknown");
match status {
400 => AsrError::InvalidInput(format!("MiniMax 400: {message}")),
401 => AsrError::Config(format!("invalid MINIMAX_API_KEY: {message}")),
402 => AsrError::Config(format!("MiniMax account out of balance: {message}")),
413 => AsrError::InvalidInput(format!("MiniMax 413: audio exceeds 50 MB ({message})")),
422 => AsrError::InvalidInput(format!("MiniMax 422: audio content rejected ({message})")),
429 => AsrError::network(
url.to_string(),
format!("MiniMax 429 rate limit: {message}"),
None,
),
s @ 500..=599 => AsrError::Inference(format!("MiniMax {s}: {message}")),
s => AsrError::Inference(format!("MiniMax {s}: {message}")),
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AsrResp {
pub text: String,
pub duration: Option<f64>,
#[serde(default)]
pub n_speakers: Option<u32>,
#[serde(default)]
pub segments: Vec<AsrSegment>,
#[serde(default)]
pub trace_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AsrSegment {
pub id: u32,
pub start: f64,
pub end: f64,
#[serde(default)]
pub speaker: Option<String>,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct OaiError {
#[serde(default)]
pub r#type: Option<String>,
pub error: OaiErrorDetail,
#[serde(default)]
pub request_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct OaiErrorDetail {
pub r#type: String,
pub message: String,
#[serde(default)]
pub http_code: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oai_error_envelope_parses_from_fixture() {
let body = r#"{
"type": "error",
"error": {
"type": "authorized_error",
"message": "login fail: missing API key (1004)",
"http_code": "401"
},
"request_id": "021785229015510a2c883cf675b9804d"
}"#;
let parsed: OaiError = serde_json::from_str(body).expect("parse");
assert_eq!(parsed.r#type.as_deref(), Some("error"));
assert_eq!(parsed.error.r#type, "authorized_error");
assert_eq!(parsed.error.http_code.as_deref(), Some("401"));
assert_eq!(
parsed.request_id.as_deref(),
Some("021785229015510a2c883cf675b9804d")
);
}
#[test]
fn transcribe_rejects_empty_buffer() {
let cfg = MiniMaxConfig::new("sk-test").unwrap();
let client = MiniMaxClient::new(&cfg);
let params = MiniMaxParams::default();
let err = client.transcribe(&[], ¶ms).expect_err("empty buffer");
assert!(matches!(err, AsrError::InvalidInput(_)));
}
#[test]
fn transcribe_rejects_oversized_buffer() {
let cfg = MiniMaxConfig::new("sk-test").unwrap();
let client = MiniMaxClient::new(&cfg);
let params = MiniMaxParams::default();
let big = vec![0u8; MAX_AUDIO_BYTES + 1];
let err = client
.transcribe(&big, ¶ms)
.expect_err("oversized buffer");
match err {
AsrError::InvalidInput(msg) => {
assert!(msg.contains("50 MB"), "{msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn client_debug_redacts_bearer_token() {
let cfg = MiniMaxConfig::new("sk-supersecret").unwrap();
let client = MiniMaxClient::new(&cfg);
let rendered = format!("{client:?}");
assert!(
!rendered.contains("sk-supersecret"),
"Debug must redact the bearer token: {rendered}"
);
assert!(
rendered.contains("redacted"),
"Debug should mention redaction: {rendered}"
);
}
#[test]
fn asr_resp_parses_verbose_json_example() {
let body = r#"{
"text": "Hello everyone. Let me check the question.",
"duration": 12.744,
"n_speakers": 2,
"segments": [
{ "id": 0, "start": 0.1, "end": 1.66, "speaker": "S1", "text": "Hello everyone." },
{ "id": 1, "start": 2.0, "end": 6.1, "speaker": "S2", "text": "Let me check the question." }
],
"trace_id": "021785229015510a2c883cf675b9804d"
}"#;
let parsed: AsrResp = serde_json::from_str(body).expect("parse");
assert_eq!(parsed.text, "Hello everyone. Let me check the question.");
assert_eq!(parsed.n_speakers, Some(2));
assert_eq!(parsed.segments.len(), 2);
assert_eq!(parsed.segments[0].speaker.as_deref(), Some("S1"));
}
#[test]
fn asr_resp_parses_json_minimal() {
let body = r#"{
"text": "Hello world",
"duration": 1.5,
"trace_id": "abc"
}"#;
let parsed: AsrResp = serde_json::from_str(body).expect("parse");
assert_eq!(parsed.text, "Hello world");
assert!(parsed.n_speakers.is_none());
assert!(parsed.segments.is_empty());
}
#[test]
fn parse_status_only_maps_each_branch() {
assert!(matches!(
parse_status_only("u", 400),
AsrError::InvalidInput(_)
));
assert!(matches!(parse_status_only("u", 401), AsrError::Config(_)));
assert!(matches!(parse_status_only("u", 402), AsrError::Config(_)));
assert!(matches!(
parse_status_only("u", 413),
AsrError::InvalidInput(_)
));
assert!(matches!(
parse_status_only("u", 422),
AsrError::InvalidInput(_)
));
assert!(matches!(
parse_status_only("u", 429),
AsrError::Network { .. }
));
assert!(matches!(
parse_status_only("u", 500),
AsrError::Inference(_)
));
}
#[test]
fn parse_error_body_extracts_message_from_envelope() {
let body = br#"{
"type": "error",
"error": {
"type": "bad_request_error",
"message": "audio duration 623.4s exceeds the limit of 500s (2013)",
"http_code": "400"
},
"request_id": "rid"
}"#;
let err = parse_error_body("https://api.minimax.io/v1/speech_to_text", 400, body);
match err {
AsrError::InvalidInput(msg) => {
assert!(msg.contains("623.4s"), "{msg}");
assert!(msg.contains("2013"), "{msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn parse_error_body_handles_non_oai_body() {
let body = b"<html>500 Internal Server Error</html>";
let err = parse_error_body("u", 500, body);
match err {
AsrError::Inference(msg) => {
assert!(msg.contains("500"), "{msg}");
}
other => panic!("expected Inference, got {other:?}"),
}
}
}