Skip to main content

zai_rs/model/audio_to_text/
request.rs

1use serde::Serialize;
2use validator::Validate;
3
4use super::super::traits::*;
5use crate::ZaiResult;
6use crate::{ZaiError, client::error::codes};
7
8/// Body parameters holder for audio transcription (used to build the multipart
9/// form).
10///
11/// `prompt` is optional and the upstream 8,000-character guidance remains
12/// advisory. The SDK validates the hot-word count and identifier lengths.
13#[derive(Clone, Serialize, Validate)]
14#[validate(schema(function = "validate_audio_to_text_body"))]
15pub struct AudioToTextBody<N>
16where
17    N: AudioToText,
18{
19    /// Model code (e.g., `glm-asr-2512`).
20    pub(super) model: N,
21
22    /// Optional prompt (the 8,000-character guidance is advisory).
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub(super) prompt: Option<String>,
25
26    /// Hot-word list, at most 100 entries.
27    #[serde(skip_serializing_if = "Vec::is_empty")]
28    #[validate(length(max = 100))]
29    pub(super) hotwords: Vec<String>,
30
31    /// Whether the server must return an SSE response.
32    pub(super) stream: bool,
33
34    /// Client-provided unique request id (6..=64 chars).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    #[validate(length(min = 6, max = 64))]
37    pub(super) request_id: Option<String>,
38
39    /// End-user id (6..=128 chars).
40    #[serde(skip_serializing_if = "Option::is_none")]
41    #[validate(length(min = 6, max = 128))]
42    pub(super) user_id: Option<String>,
43}
44
45impl<N> std::fmt::Debug for AudioToTextBody<N>
46where
47    N: AudioToText + std::fmt::Debug,
48{
49    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        formatter
51            .debug_struct("AudioToTextBody")
52            .field("model", &self.model)
53            .field("prompt_configured", &self.prompt.is_some())
54            .field("hotword_count", &self.hotwords.len())
55            .field("stream", &self.stream)
56            .field("request_id_configured", &self.request_id.is_some())
57            .field("user_id_configured", &self.user_id.is_some())
58            .finish()
59    }
60}
61
62fn validate_audio_to_text_body<N>(
63    body: &AudioToTextBody<N>,
64) -> Result<(), validator::ValidationError>
65where
66    N: AudioToText,
67{
68    if body
69        .prompt
70        .as_deref()
71        .is_some_and(|prompt| prompt.trim().is_empty())
72    {
73        return Err(validator::ValidationError::new("prompt_must_not_be_blank"));
74    }
75    if body
76        .hotwords
77        .iter()
78        .any(|hotword| hotword.trim().is_empty())
79    {
80        return Err(validator::ValidationError::new(
81            "hotwords_must_not_be_blank",
82        ));
83    }
84    if body.request_id.as_deref().is_some_and(|id| id.trim() != id)
85        || body.user_id.as_deref().is_some_and(|id| id.trim() != id)
86    {
87        return Err(validator::ValidationError::new(
88            "identifier_must_not_have_surrounding_whitespace",
89        ));
90    }
91    Ok(())
92}
93
94impl<N> AudioToTextBody<N>
95where
96    N: AudioToText,
97{
98    /// Create a new transcription body for the given model (all options empty).
99    pub fn new(model: N) -> Self {
100        Self {
101            model,
102            prompt: None,
103            hotwords: Vec::new(),
104            stream: false,
105            request_id: None,
106            user_id: None,
107        }
108    }
109
110    /// Borrow the selected model marker.
111    pub fn model(&self) -> &N {
112        &self.model
113    }
114
115    /// Borrow the optional transcription context prompt.
116    pub fn prompt(&self) -> Option<&str> {
117        self.prompt.as_deref()
118    }
119
120    /// Borrow the configured hot words.
121    pub fn hotwords(&self) -> &[String] {
122        &self.hotwords
123    }
124
125    /// Whether this body requests an SSE response.
126    pub fn is_streaming(&self) -> bool {
127        self.stream
128    }
129
130    /// Borrow the optional client request identifier.
131    pub fn request_id(&self) -> Option<&str> {
132        self.request_id.as_deref()
133    }
134
135    /// Borrow the optional end-user identifier.
136    pub fn user_id(&self) -> Option<&str> {
137        self.user_id.as_deref()
138    }
139
140    pub(super) fn with_stream(mut self, stream: bool) -> Self {
141        self.stream = stream;
142        self
143    }
144
145    /// Set an optional prompt (advisory 8000-char limit; not hard-rejected).
146    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
147        self.prompt = Some(prompt.into());
148        self
149    }
150
151    /// Set the hot-word list (at most 100 entries).
152    pub fn with_hotwords(mut self, hotwords: Vec<String>) -> ZaiResult<Self> {
153        if hotwords.len() > 100 {
154            return Err(ZaiError::ApiError {
155                code: codes::SDK_VALIDATION,
156                message: "hotwords must contain at most 100 entries".to_string(),
157            });
158        }
159        self.hotwords = hotwords;
160        Ok(self)
161    }
162
163    /// Set the client-side request id (6..=64 chars).
164    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
165        self.request_id = Some(request_id.into());
166        self
167    }
168
169    /// Set the end-user id (6..=128 chars).
170    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
171        self.user_id = Some(user_id.into());
172        self
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::model::audio_to_text::GlmAsr;
180
181    #[test]
182    fn validation_rejects_blank_optional_text_fields() {
183        assert!(
184            AudioToTextBody::new(GlmAsr {})
185                .with_prompt(" ")
186                .validate()
187                .is_err()
188        );
189        assert!(
190            AudioToTextBody::new(GlmAsr {})
191                .with_hotwords(vec!["valid".into(), String::new()])
192                .unwrap()
193                .validate()
194                .is_err()
195        );
196    }
197
198    #[test]
199    fn identifiers_and_hotwords_follow_frozen_limits() {
200        assert!(
201            AudioToTextBody::new(GlmAsr {})
202                .with_request_id("short")
203                .validate()
204                .is_err()
205        );
206        assert!(
207            AudioToTextBody::new(GlmAsr {})
208                .with_request_id(" request-1 ")
209                .validate()
210                .is_err()
211        );
212        assert!(
213            AudioToTextBody::new(GlmAsr {})
214                .with_user_id("12345")
215                .validate()
216                .is_err()
217        );
218        assert!(
219            AudioToTextBody::new(GlmAsr {})
220                .with_request_id("r".repeat(64))
221                .with_user_id("u".repeat(128))
222                .validate()
223                .is_ok()
224        );
225        assert!(
226            AudioToTextBody::new(GlmAsr {})
227                .with_prompt("p".repeat(8_001))
228                .validate()
229                .is_ok(),
230            "the 8,000-character prompt guidance is advisory"
231        );
232    }
233
234    #[test]
235    fn debug_redacts_request_content_and_identifiers() {
236        let body = AudioToTextBody::new(GlmAsr {})
237            .with_prompt("private transcript")
238            .with_hotwords(vec!["private-hotword".into()])
239            .unwrap()
240            .with_request_id("request-secret")
241            .with_user_id("user-secret");
242        let debug = format!("{body:?}");
243        for secret in [
244            "private transcript",
245            "private-hotword",
246            "request-secret",
247            "user-secret",
248        ] {
249            assert!(!debug.contains(secret));
250        }
251        assert!(debug.contains("hotword_count: 1"));
252    }
253}