rustglm 1.0.0

Production-oriented async Rust SDK for Zhipu GLM, Realtime, Batch, RAG, and MCP
Documentation
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Serialize};

use crate::{
    ChatCompletionRequest, ChatCompletionResponse, ChatMessage, ContentPart, MessageRole, Result,
    SdkError,
};

pub const GLM_4_VOICE_MODEL: &str = "glm-4-voice";
pub const GLM_4_VOICE_OUTPUT_SAMPLE_RATE: u32 = 44_100;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(transparent)]
pub struct Glm4VoiceRequest {
    inner: ChatCompletionRequest,
}

impl Default for Glm4VoiceRequest {
    fn default() -> Self {
        Self::new()
    }
}

impl Glm4VoiceRequest {
    pub fn new() -> Self {
        Self {
            inner: ChatCompletionRequest::new(GLM_4_VOICE_MODEL),
        }
    }

    pub fn from_wav(prompt: impl Into<String>, wav: &[u8]) -> Result<Self> {
        if wav.is_empty() {
            return Err(SdkError::Validation(
                "GLM-4-Voice input WAV cannot be empty".into(),
            ));
        }
        let prompt = prompt.into();
        if prompt.trim().is_empty() {
            return Err(SdkError::Validation(
                "GLM-4-Voice prompt cannot be empty".into(),
            ));
        }
        Ok(Self::new().message(ChatMessage::multimodal(
            MessageRole::User,
            vec![
                ContentPart::text(prompt),
                ContentPart::input_audio(STANDARD.encode(wav), "wav"),
            ],
        )))
    }

    pub fn message(mut self, value: ChatMessage) -> Self {
        self.inner.messages.push(value);
        self
    }

    pub fn temperature(mut self, value: f32) -> Self {
        self.inner.temperature = Some(value);
        self
    }

    pub fn max_tokens(mut self, value: u32) -> Self {
        self.inner.max_tokens = Some(value);
        self
    }

    pub fn request_id(mut self, value: impl Into<String>) -> Self {
        self.inner.request_id = Some(value.into());
        self
    }

    pub fn user_id(mut self, value: impl Into<String>) -> Self {
        self.inner.user_id = Some(value.into());
        self
    }

    pub fn as_chat_request(&self) -> &ChatCompletionRequest {
        &self.inner
    }

    pub fn into_chat_request(self) -> ChatCompletionRequest {
        self.inner
    }
}

impl ChatCompletionResponse {
    pub fn audio_bytes(&self) -> Result<Option<Vec<u8>>> {
        self.choices
            .first()
            .and_then(|choice| choice.message.audio.as_ref())
            .and_then(|audio| audio.data.as_deref())
            .map(|data| {
                STANDARD.decode(data).map_err(|error| SdkError::Decode {
                    message: format!("GLM-4-Voice audio is not valid base64: {error}"),
                    body: data.to_owned(),
                })
            })
            .transpose()
    }

    pub fn audio_wav(&self) -> Result<Option<Vec<u8>>> {
        self.audio_bytes()?
            .map(|pcm| pcm16_mono_wav(&pcm, GLM_4_VOICE_OUTPUT_SAMPLE_RATE))
            .transpose()
    }
}

pub fn pcm16_mono_wav(pcm: &[u8], sample_rate: u32) -> Result<Vec<u8>> {
    #[allow(clippy::manual_is_multiple_of)]
    if pcm.len() % 2 != 0 {
        return Err(SdkError::Validation(
            "PCM16 audio must contain an even number of bytes".into(),
        ));
    }
    if sample_rate == 0 {
        return Err(SdkError::Validation(
            "WAV sample rate must be greater than zero".into(),
        ));
    }
    let data_size = u32::try_from(pcm.len())
        .map_err(|_| SdkError::Validation("PCM audio is too large for a WAV file".into()))?;
    let riff_size = data_size
        .checked_add(36)
        .ok_or_else(|| SdkError::Validation("WAV size overflow".into()))?;
    let byte_rate = sample_rate
        .checked_mul(2)
        .ok_or_else(|| SdkError::Validation("WAV byte rate overflow".into()))?;
    let mut wav = Vec::with_capacity(pcm.len() + 44);
    wav.extend_from_slice(b"RIFF");
    wav.extend_from_slice(&riff_size.to_le_bytes());
    wav.extend_from_slice(b"WAVEfmt ");
    wav.extend_from_slice(&16u32.to_le_bytes());
    wav.extend_from_slice(&1u16.to_le_bytes());
    wav.extend_from_slice(&1u16.to_le_bytes());
    wav.extend_from_slice(&sample_rate.to_le_bytes());
    wav.extend_from_slice(&byte_rate.to_le_bytes());
    wav.extend_from_slice(&2u16.to_le_bytes());
    wav.extend_from_slice(&16u16.to_le_bytes());
    wav.extend_from_slice(b"data");
    wav.extend_from_slice(&data_size.to_le_bytes());
    wav.extend_from_slice(pcm);
    Ok(wav)
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn voice_request_encodes_wav_using_official_wire_format() {
        let request = Glm4VoiceRequest::from_wav("repeat slowly", b"RIFFdata").unwrap();
        let value = serde_json::to_value(request).unwrap();
        assert_eq!(value["model"], "glm-4-voice");
        assert_eq!(value["messages"][0]["content"][1]["type"], "input_audio");
        assert_eq!(
            value["messages"][0]["content"][1]["input_audio"]["format"],
            "wav"
        );
        assert_eq!(
            value["messages"][0]["content"][1]["input_audio"]["data"],
            "UklGRmRhdGE="
        );
    }

    #[test]
    fn voice_response_decodes_pcm_and_wraps_wav() {
        let response: ChatCompletionResponse = serde_json::from_value(json!({
            "model":"glm-4-voice",
            "choices":[{"message":{"role":"assistant","content":"ok","audio":{
                "id":"audio-1","expires_at":1749187238,"data":"AQIDBA=="
            }}}]
        }))
        .unwrap();
        assert_eq!(response.audio_bytes().unwrap(), Some(vec![1, 2, 3, 4]));
        let wav = response.audio_wav().unwrap().unwrap();
        assert_eq!(&wav[..4], b"RIFF");
        assert_eq!(&wav[8..12], b"WAVE");
        assert_eq!(&wav[44..], &[1, 2, 3, 4]);
    }

    #[test]
    fn voice_validation_rejects_invalid_input_and_audio() {
        assert!(Glm4VoiceRequest::from_wav("", b"wav").is_err());
        assert!(Glm4VoiceRequest::from_wav("prompt", b"").is_err());
        assert!(pcm16_mono_wav(&[1], 44_100).is_err());
        assert!(pcm16_mono_wav(&[1, 2], 0).is_err());
        let response: ChatCompletionResponse = serde_json::from_value(json!({
            "choices":[{"message":{"audio":{"data":"%%%"}}}]
        }))
        .unwrap();
        assert!(response.audio_bytes().is_err());
    }

    #[test]
    fn voice_builder_and_empty_response_helpers_are_complete() {
        let request = Glm4VoiceRequest::new()
            .message(ChatMessage::user("text"))
            .temperature(0.5)
            .max_tokens(256)
            .request_id("request-id")
            .user_id("user-id");
        assert_eq!(request.as_chat_request().temperature, Some(0.5));
        let request = request.into_chat_request();
        assert_eq!(request.max_tokens, Some(256));
        assert_eq!(request.request_id.as_deref(), Some("request-id"));
        assert_eq!(request.user_id.as_deref(), Some("user-id"));
        let response = ChatCompletionResponse::default();
        assert_eq!(response.audio_bytes().unwrap(), None);
        assert_eq!(response.audio_wav().unwrap(), None);
        let wav = pcm16_mono_wav(&[0, 0, 1, 0], 8_000).unwrap();
        assert_eq!(u32::from_le_bytes(wav[24..28].try_into().unwrap()), 8_000);
        assert_eq!(u32::from_le_bytes(wav[28..32].try_into().unwrap()), 16_000);
    }
}