zai_rs/model/audio_to_text/
request.rs1use serde::Serialize;
2use validator::Validate;
3
4use super::super::traits::*;
5use crate::ZaiResult;
6use crate::{ZaiError, client::error::codes};
7
8#[derive(Clone, Serialize, Validate)]
14#[validate(schema(function = "validate_audio_to_text_body"))]
15pub struct AudioToTextBody<N>
16where
17 N: AudioToText,
18{
19 pub(super) model: N,
21
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub(super) prompt: Option<String>,
25
26 #[serde(skip_serializing_if = "Vec::is_empty")]
28 #[validate(length(max = 100))]
29 pub(super) hotwords: Vec<String>,
30
31 pub(super) stream: bool,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
36 #[validate(length(min = 6, max = 64))]
37 pub(super) request_id: Option<String>,
38
39 #[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 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 pub fn model(&self) -> &N {
112 &self.model
113 }
114
115 pub fn prompt(&self) -> Option<&str> {
117 self.prompt.as_deref()
118 }
119
120 pub fn hotwords(&self) -> &[String] {
122 &self.hotwords
123 }
124
125 pub fn is_streaming(&self) -> bool {
127 self.stream
128 }
129
130 pub fn request_id(&self) -> Option<&str> {
132 self.request_id.as_deref()
133 }
134
135 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 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
147 self.prompt = Some(prompt.into());
148 self
149 }
150
151 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 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 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}