Skip to main content

zai_rs/model/
chat_base_request.rs

1//! Shared chat-completion request body.
2//!
3//! [`ChatBody`](crate::model::chat_base_request::ChatBody) is used by chat-completion
4//! endpoints. The generic type parameters enforce compile-time compatibility
5//! between model and message types.
6
7use serde::Serialize;
8use validator::*;
9
10use super::{tools::*, traits::*};
11
12/// Validated request body for a chat-completion operation.
13///
14/// This type stores the union of frozen chat request fields. Its fields
15/// are private, and capability-gated builders expose only the subset accepted
16/// by the selected text, vision, or audio model family.
17///
18/// # Examples
19///
20/// ```
21/// use zai_rs::model::{
22///     chat_base_request::ChatBody,
23///     chat_message_types::TextMessage,
24///     chat_models::GLM5_2,
25/// };
26///
27/// let chat_body = ChatBody::new(GLM5_2 {}, TextMessage::user("Hello"))
28///     .add_message(TextMessage::assistant("How can I help?"))
29///     .with_temperature(0.7)
30///     .with_max_tokens(1_000);
31/// ```
32#[derive(Clone, Validate, Serialize)]
33#[validate(schema(function = "validate_chat_body"))]
34pub struct ChatBody<N, M>
35where
36    N: ChatRequestModel,
37    M: Serialize,
38    (N, M): Bounded,
39{
40    /// The model to use for the chat completion.
41    model: N,
42
43    /// A list of messages comprising the conversation so far.
44    #[validate(length(min = 1))]
45    messages: Vec<M>,
46
47    /// A unique identifier for the request. Optional field that will be omitted
48    /// from serialization if not provided.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    #[validate(length(min = 6, max = 64))]
51    request_id: Option<String>,
52
53    /// Thinking-mode configuration for models that support extended reasoning.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    thinking: Option<ThinkingType>,
56
57    /// Controls the depth of reasoning when thinking mode is enabled. Only
58    /// available for GLM-5.2 and above (models that implement
59    /// `ReasoningEffortEnable`). See [`ReasoningEffort`] for the available
60    /// levels.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    reasoning_effort: Option<ReasoningEffort>,
63
64    /// Whether to use sampling during generation. When `true`, the model will
65    /// use probabilistic sampling; when `false`, it will use deterministic
66    /// generation.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    do_sample: Option<bool>,
69
70    /// Whether to stream back partial message deltas as they are generated.
71    /// When `true`, responses will be sent as server-sent events.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    stream: Option<bool>,
74
75    /// Whether to enable streaming of tool calls (streaming function call
76    /// parameters). Supported by GLM-5.2, GLM-5.1, GLM-5, GLM-5-Turbo, GLM-4.7,
77    /// and GLM-4.6 models. Defaults to false when omitted.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    tool_stream: Option<bool>,
80
81    /// Controls randomness in the output. Higher values (closer to 1.0) make
82    /// the output more random, while lower values (closer to 0.0) make it
83    /// more deterministic. Must be between 0.0 and 1.0.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    #[validate(range(min = 0.0, max = 1.0))]
86    temperature: Option<f64>,
87
88    /// Controls diversity via nucleus sampling. Only tokens with cumulative
89    /// probability up to `top_p` are considered. Must be between 0.0 and
90    /// 1.0, with a minimum non-zero value of 0.01.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    #[validate(range(min = 0.01, max = 1.0))]
93    top_p: Option<f64>,
94
95    /// The maximum number of tokens to generate in the completion.
96    /// Must be between 1 and 131,072. Individual models may impose a smaller
97    /// service-side maximum.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    #[validate(range(min = 1, max = 131072))]
100    max_tokens: Option<u32>,
101
102    /// A list of tools the model may call. Currently supports function calling,
103    /// web search, and retrieval tools.
104    /// Note: server expects an array; we model this as a vector of tool items.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    #[validate(length(max = 128))]
107    tools: Option<Vec<Tools>>,
108
109    /// Controls whether the model calls a tool, and which one. Only meaningful
110    /// when [`tools`](Self::tools) is set. See [`ToolChoice`] for the wire
111    /// form (`"auto"`).
112    #[serde(skip_serializing_if = "Option::is_none")]
113    tool_choice: Option<ToolChoice>,
114
115    /// A unique identifier representing your end-user, which can help monitor
116    /// and detect abuse. Must be between 6 and 128 characters long.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    #[validate(length(min = 6, max = 128))]
119    user_id: Option<String>,
120
121    /// Up to four non-empty sequences where the API will stop generation.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    #[validate(length(min = 1, max = 4))]
124    stop: Option<Vec<String>>,
125
126    /// An object specifying the format that the model must output.
127    /// Can be either text or JSON object format.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    response_format: Option<ResponseFormat>,
130
131    /// Whether generated audio-model media carries the provider watermark.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    watermark_enabled: Option<bool>,
134}
135
136impl<N, M> std::fmt::Debug for ChatBody<N, M>
137where
138    N: ChatRequestModel + std::fmt::Debug,
139    M: Serialize,
140    (N, M): Bounded,
141{
142    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        formatter
144            .debug_struct("ChatBody")
145            .field("model", &self.model)
146            .field("messages_count", &self.messages.len())
147            .field("request_id_set", &self.request_id.is_some())
148            .field("thinking", &self.thinking)
149            .field("reasoning_effort", &self.reasoning_effort)
150            .field("do_sample", &self.do_sample)
151            .field("stream", &self.stream)
152            .field("tool_stream", &self.tool_stream)
153            .field("temperature", &self.temperature)
154            .field("top_p", &self.top_p)
155            .field("max_tokens", &self.max_tokens)
156            .field("tools_count", &self.tools.as_ref().map(std::vec::Vec::len))
157            .field("tool_choice", &self.tool_choice)
158            .field("user_id_set", &self.user_id.is_some())
159            .field("stop_count", &self.stop.as_ref().map(std::vec::Vec::len))
160            .field("response_format", &self.response_format)
161            .field("watermark_enabled", &self.watermark_enabled)
162            .finish()
163    }
164}
165
166fn validate_chat_body<N, M>(body: &ChatBody<N, M>) -> Result<(), ValidationError>
167where
168    N: ChatRequestModel,
169    M: Serialize,
170    (N, M): Bounded,
171{
172    if body
173        .temperature
174        .is_some_and(|temperature| !temperature.is_finite())
175    {
176        return Err(ValidationError::new("temperature_must_be_finite"));
177    }
178    if body.top_p.is_some_and(|top_p| !top_p.is_finite()) {
179        return Err(ValidationError::new("top_p_must_be_finite"));
180    }
181    if body
182        .max_tokens
183        .is_some_and(|max_tokens| max_tokens > N::MAX_TOKENS)
184    {
185        return Err(ValidationError::new("max_tokens_exceeds_model_limit"));
186    }
187    if body
188        .stop
189        .as_ref()
190        .is_some_and(|values| values.iter().any(|value| value.trim().is_empty()))
191    {
192        return Err(ValidationError::new("stop_must_not_be_blank"));
193    }
194    if body
195        .tools
196        .as_ref()
197        .is_some_and(|tools| tools.iter().any(|tool| tool.validate().is_err()))
198    {
199        return Err(ValidationError::new("invalid_tool"));
200    }
201    if body.tool_choice.is_some() && body.tools.as_ref().is_none_or(Vec::is_empty) {
202        return Err(ValidationError::new("tool_choice_requires_tools"));
203    }
204    Ok(())
205}
206
207impl<N, M> ChatBody<N, M>
208where
209    N: ChatRequestModel,
210    M: Serialize,
211    (N, M): Bounded,
212{
213    /// Create a new chat request body from a model and the first message batch.
214    pub fn new(model: N, messages: M) -> Self {
215        Self {
216            model,
217            messages: vec![messages],
218            request_id: None,
219            thinking: None,
220            reasoning_effort: None,
221            do_sample: None,
222            stream: None,
223            tool_stream: None,
224            temperature: None,
225            top_p: None,
226            max_tokens: None,
227            tools: None,
228            tool_choice: None,
229            user_id: None,
230            stop: None,
231            response_format: None,
232            watermark_enabled: None,
233        }
234    }
235
236    /// Add one message to the conversation.
237    pub fn add_message(mut self, message: M) -> Self {
238        self.messages.push(message);
239        self
240    }
241    /// Add multiple messages to the conversation at once.
242    pub fn extend_messages(mut self, messages: impl IntoIterator<Item = M>) -> Self {
243        self.messages.extend(messages);
244        self
245    }
246    /// Set the client-side request id (used for tracing/dedup).
247    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
248        self.request_id = Some(request_id.into());
249        self
250    }
251    /// Enable/disable sampling (`do_sample`).
252    pub fn with_do_sample(mut self, do_sample: bool) -> Self {
253        self.do_sample = Some(do_sample);
254        self
255    }
256    /// Set the sampling temperature.
257    pub fn with_temperature(mut self, temperature: f64) -> Self {
258        self.temperature = Some(temperature);
259        self
260    }
261    /// Set the nucleus-sampling probability (`top_p`).
262    pub fn with_top_p(mut self, top_p: f64) -> Self {
263        self.top_p = Some(top_p);
264        self
265    }
266    /// Set the maximum number of tokens to generate.
267    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
268        self.max_tokens = Some(max_tokens);
269        self
270    }
271    /// Set the end-user id (used for abuse monitoring).
272    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
273        self.user_id = Some(user_id.into());
274        self
275    }
276    /// Add a stop sequence that halts generation when encountered.
277    pub fn with_stop(mut self, stop: impl Into<String>) -> Self {
278        self.stop.get_or_insert_with(Vec::new).push(stop.into());
279        self
280    }
281
282    /// Borrow the selected model.
283    pub const fn model(&self) -> &N {
284        &self.model
285    }
286
287    /// Borrow the complete conversation in wire order.
288    pub fn messages(&self) -> &[M] {
289        &self.messages
290    }
291
292    /// Client-provided request identifier.
293    pub fn request_id(&self) -> Option<&str> {
294        self.request_id.as_deref()
295    }
296
297    /// Thinking configuration, when supported and configured.
298    pub const fn thinking(&self) -> Option<&ThinkingType> {
299        self.thinking.as_ref()
300    }
301
302    /// Reasoning depth, when supported and configured.
303    pub const fn reasoning_effort(&self) -> Option<ReasoningEffort> {
304        self.reasoning_effort
305    }
306
307    /// Explicit sampling toggle.
308    pub const fn do_sample(&self) -> Option<bool> {
309        self.do_sample
310    }
311
312    /// Streaming flag managed by the [`ChatCompletion`](super::chat::ChatCompletion)
313    /// type state.
314    pub const fn stream(&self) -> Option<bool> {
315        self.stream
316    }
317
318    /// Incremental tool-call flag managed by the streaming builder.
319    pub const fn tool_stream(&self) -> Option<bool> {
320        self.tool_stream
321    }
322
323    /// Configured sampling temperature.
324    pub const fn temperature(&self) -> Option<f64> {
325        self.temperature
326    }
327
328    /// Configured nucleus-sampling probability.
329    pub const fn top_p(&self) -> Option<f64> {
330        self.top_p
331    }
332
333    /// Configured output-token limit.
334    pub const fn max_tokens(&self) -> Option<u32> {
335        self.max_tokens
336    }
337
338    /// Attached tools, if this model family supports them.
339    pub fn tools(&self) -> Option<&[Tools]> {
340        self.tools.as_deref()
341    }
342
343    /// Configured automatic tool-selection policy.
344    pub const fn tool_choice(&self) -> Option<ToolChoice> {
345        self.tool_choice
346    }
347
348    /// End-user identifier used for abuse monitoring.
349    pub fn user_id(&self) -> Option<&str> {
350        self.user_id.as_deref()
351    }
352
353    /// Borrow configured stop sequences.
354    pub fn stop(&self) -> Option<&[String]> {
355        self.stop.as_deref()
356    }
357
358    /// Text response format, when supported and configured.
359    pub const fn response_format(&self) -> Option<ResponseFormat> {
360        self.response_format
361    }
362
363    /// Audio-model watermark setting, when configured.
364    pub const fn watermark_enabled(&self) -> Option<bool> {
365        self.watermark_enabled
366    }
367
368    pub(crate) fn set_stream(&mut self, stream: Option<bool>) {
369        self.stream = stream;
370    }
371
372    pub(crate) fn clear_tool_stream(&mut self) {
373        self.tool_stream = None;
374    }
375}
376
377impl<N, M> ChatBody<N, M>
378where
379    N: ChatToolSupport,
380    M: Serialize,
381    (N, M): Bounded,
382{
383    /// Add one schema-compatible tool.
384    pub fn add_tool(mut self, tool: N::Tool) -> Self {
385        self.tools.get_or_insert_default().push(tool.into());
386        self
387    }
388
389    /// Add multiple schema-compatible tools.
390    pub fn add_tools(mut self, tools: impl IntoIterator<Item = N::Tool>) -> Self {
391        self.tools
392            .get_or_insert_default()
393            .extend(tools.into_iter().map(Into::into));
394        self
395    }
396
397    /// Let the model choose automatically among the attached tools.
398    pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
399        self.tool_choice = Some(tool_choice);
400        self
401    }
402
403    /// Remove attached tools and their selection policy.
404    pub fn clear_tools(mut self) -> Self {
405        self.tools = None;
406        self.tool_choice = None;
407        self
408    }
409}
410
411impl<N, M> ChatBody<N, M>
412where
413    N: ResponseFormatEnable,
414    M: Serialize,
415    (N, M): Bounded,
416{
417    /// Set the response format for a text chat model.
418    pub fn with_response_format(mut self, format: ResponseFormat) -> Self {
419        self.response_format = Some(format);
420        self
421    }
422}
423
424impl<N, M> ChatBody<N, M>
425where
426    N: WatermarkEnable,
427    M: Serialize,
428    (N, M): Bounded,
429{
430    /// Enable or disable provider watermarking for audio-model output.
431    pub fn with_watermark_enabled(mut self, enabled: bool) -> Self {
432        self.watermark_enabled = Some(enabled);
433        self
434    }
435}
436
437impl<N, M> ChatBody<N, M>
438where
439    N: ChatRequestModel + ThinkEnable,
440    M: Serialize,
441    (N, M): Bounded,
442{
443    /// Configure extended reasoning for a model that implements
444    /// [`ThinkEnable`].
445    ///
446    /// # Examples
447    ///
448    /// ```
449    /// use zai_rs::model::{
450    ///     chat_base_request::ChatBody,
451    ///     chat_message_types::TextMessage,
452    ///     chat_models::GLM5_2,
453    ///     tools::ThinkingType,
454    /// };
455    ///
456    /// let chat_body = ChatBody::new(GLM5_2 {}, TextMessage::user("Solve this"))
457    ///     .with_thinking(ThinkingType::enabled());
458    /// ```
459    pub fn with_thinking(mut self, thinking: ThinkingType) -> Self {
460        self.thinking = Some(thinking);
461        self
462    }
463}
464
465impl<N, M> ChatBody<N, M>
466where
467    N: ChatRequestModel + ReasoningEffortEnable,
468    M: Serialize,
469    (N, M): Bounded,
470{
471    /// Set the `reasoning_effort` level that controls how much reasoning the
472    /// model performs when thinking mode is enabled.
473    ///
474    /// Available only for models implementing [`ReasoningEffortEnable`].
475    /// Typically combined with
476    /// [`with_thinking`](Self::with_thinking) to enable thinking first.
477    ///
478    /// # Examples
479    ///
480    /// ```rust
481    /// use zai_rs::model::{
482    ///     GLM5_2, TextMessage,
483    ///     chat_base_request::ChatBody,
484    ///     tools::{ReasoningEffort, ThinkingType},
485    /// };
486    ///
487    /// let chat_body = ChatBody::new(GLM5_2 {}, TextMessage::user("Solve this"))
488    ///     .with_thinking(ThinkingType::enabled())
489    ///     .with_reasoning_effort(ReasoningEffort::Max);
490    /// ```
491    pub fn with_reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
492        self.reasoning_effort = Some(effort);
493        self
494    }
495}
496
497impl<N, M> ChatBody<N, M>
498where
499    N: ToolStreamEnable,
500    M: Serialize,
501    (N, M): Bounded,
502{
503    /// Enable streaming tool calls and the containing chat stream.
504    pub(crate) fn with_tool_stream(mut self, tool_stream: bool) -> Self {
505        self.tool_stream = Some(tool_stream);
506        if tool_stream {
507            self.stream = Some(true);
508        }
509        self
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::model::{
517        chat_message_types::{TextMessage, VisionMessage, VoiceMessage},
518        chat_models::{GLM4_5_air, GLM4_6, GLM4_6v, GLM4_voice, GLM5_2},
519    };
520    use validator::Validate;
521
522    #[test]
523    fn test_with_tool_stream_sets_both_fields() {
524        let body: ChatBody<GLM4_6, TextMessage> =
525            ChatBody::new(GLM4_6 {}, TextMessage::user("test"));
526        let body = body.with_tool_stream(true);
527        assert_eq!(body.tool_stream(), Some(true));
528        assert_eq!(body.stream(), Some(true));
529    }
530
531    #[test]
532    fn test_with_tool_stream_false_does_not_force_stream() {
533        let body: ChatBody<GLM4_6, TextMessage> =
534            ChatBody::new(GLM4_6 {}, TextMessage::user("test"));
535        let body = body.with_tool_stream(false);
536        assert_eq!(body.tool_stream(), Some(false));
537        // stream should not be forced to true when tool_stream is false
538        assert_ne!(body.stream(), Some(true));
539    }
540
541    #[test]
542    fn test_add_tool_stores_function_tool() {
543        let body: ChatBody<GLM4_6, TextMessage> =
544            ChatBody::new(GLM4_6 {}, TextMessage::user("test"));
545        let tool = crate::model::tools::Function::new(
546            "test_fn",
547            "A test function",
548            serde_json::json!({"type": "object"}),
549        );
550        let body = body.add_tool(crate::model::tools::Tools::Function { function: tool });
551        assert_eq!(body.tools().map(|tools| tools.len()), Some(1));
552    }
553
554    #[test]
555    fn test_extend_messages() {
556        let body: ChatBody<GLM4_6, TextMessage> =
557            ChatBody::new(GLM4_6 {}, TextMessage::user("first"));
558        let body = body.extend_messages(vec![
559            TextMessage::assistant("second"),
560            TextMessage::user("third"),
561        ]);
562        assert_eq!(body.messages().len(), 3);
563        match &body.messages()[0] {
564            TextMessage::User { content } => assert_eq!(content, "first"),
565            _ => panic!("Expected User message"),
566        }
567    }
568
569    #[test]
570    fn test_add_message() {
571        let body: ChatBody<GLM4_6, TextMessage> =
572            ChatBody::new(GLM4_6 {}, TextMessage::user("first"));
573        let body = body.add_message(TextMessage::assistant("second"));
574        assert_eq!(body.messages().len(), 2);
575    }
576
577    #[test]
578    fn test_glm52_reasoning_effort_builder() {
579        // reasoning_effort is only available on GLM-5.2+ (ReasoningEffortEnable)
580        let body: ChatBody<GLM5_2, TextMessage> = ChatBody::new(GLM5_2 {}, TextMessage::user("hi"))
581            .with_thinking(ThinkingType::enabled())
582            .with_reasoning_effort(ReasoningEffort::Max);
583        assert_eq!(body.reasoning_effort(), Some(ReasoningEffort::Max));
584        assert!(body.thinking().is_some());
585    }
586
587    #[test]
588    fn test_glm52_serializes_model_name() {
589        let body: ChatBody<GLM5_2, TextMessage> = ChatBody::new(GLM5_2 {}, TextMessage::user("hi"));
590        let json = serde_json::to_value(&body).unwrap();
591        assert_eq!(json["model"], "glm-5.2");
592        // reasoning_effort must be omitted when not set
593        assert!(json.get("reasoning_effort").is_none());
594    }
595
596    #[test]
597    fn test_sampling_parameters_serialize_without_f32_expansion() {
598        let body: ChatBody<GLM4_5_air, TextMessage> =
599            ChatBody::new(GLM4_5_air {}, TextMessage::user("hi"))
600                .with_temperature(0.7)
601                .with_top_p(0.9);
602
603        let json = serde_json::to_value(&body).unwrap();
604        assert_eq!(json["temperature"], serde_json::json!(0.7));
605        assert_eq!(json["top_p"], serde_json::json!(0.9));
606
607        let serialized = serde_json::to_string(&body).unwrap();
608        assert!(serialized.contains("\"temperature\":0.7"));
609        assert!(serialized.contains("\"top_p\":0.9"));
610        assert!(!serialized.contains("0.699999"));
611        assert!(!serialized.contains("0.899999"));
612    }
613
614    #[test]
615    fn test_reasoning_effort_serializes_level() {
616        let body: ChatBody<GLM5_2, TextMessage> = ChatBody::new(GLM5_2 {}, TextMessage::user("hi"))
617            .with_reasoning_effort(ReasoningEffort::Xhigh);
618        let json = serde_json::to_value(&body).unwrap();
619        assert_eq!(json["reasoning_effort"], "xhigh");
620    }
621
622    #[test]
623    fn test_tool_choice_is_omitted_by_default() {
624        let body: ChatBody<GLM4_6, TextMessage> = ChatBody::new(GLM4_6 {}, TextMessage::user("hi"));
625        assert!(body.tool_choice().is_none());
626        let json = serde_json::to_value(&body).unwrap();
627        assert!(json.get("tool_choice").is_none());
628    }
629
630    #[test]
631    fn test_with_tool_choice_serializes_only_auto() {
632        let body: ChatBody<GLM4_6, TextMessage> = ChatBody::new(GLM4_6 {}, TextMessage::user("hi"))
633            .add_tool(Tools::Function {
634                function: Function::new("lookup", "Lookup", serde_json::json!({"type": "object"})),
635            })
636            .with_tool_choice(crate::model::tools::ToolChoice::auto());
637        assert!(body.validate().is_ok());
638        let json = serde_json::to_value(&body).unwrap();
639        assert_eq!(json["tool_choice"], serde_json::json!("auto"));
640    }
641
642    #[test]
643    fn test_with_response_format_serializes() {
644        use crate::model::tools::ResponseFormat;
645        let body: ChatBody<GLM4_6, TextMessage> = ChatBody::new(GLM4_6 {}, TextMessage::user("hi"))
646            .with_response_format(ResponseFormat::JsonObject);
647        assert_eq!(body.response_format(), Some(ResponseFormat::JsonObject));
648        let json = serde_json::to_value(&body).unwrap();
649        assert_eq!(json["response_format"]["type"], "json_object");
650    }
651
652    #[test]
653    fn validation_matches_current_chat_limits() {
654        let body: ChatBody<GLM4_6, TextMessage> = ChatBody::new(GLM4_6 {}, TextMessage::user("hi"))
655            .with_request_id("123456")
656            .with_top_p(0.01)
657            .with_max_tokens(131_072)
658            .with_stop("one")
659            .with_stop("two")
660            .with_stop("three")
661            .with_stop("four");
662        assert!(body.validate().is_ok());
663
664        assert!(body.clone().with_stop("five").validate().is_err());
665        assert!(body.clone().with_stop(" ").validate().is_err());
666        assert!(body.clone().with_top_p(0.0).validate().is_err());
667        assert!(body.clone().with_temperature(f64::NAN).validate().is_err());
668        assert!(body.with_request_id("short").validate().is_err());
669    }
670
671    #[test]
672    fn vision_accepts_only_the_function_tool_wire_shape() {
673        let body = ChatBody::new(GLM4_6v {}, VisionMessage::new_user())
674            .add_tool(Function::new(
675                "inspect",
676                "Inspect the image",
677                serde_json::json!({"type": "object"}),
678            ))
679            .with_tool_choice(ToolChoice::auto());
680        assert!(body.validate().is_ok());
681
682        let json = serde_json::to_value(body).unwrap();
683        assert_eq!(json["tools"][0]["type"], "function");
684        assert_eq!(json["tool_choice"], "auto");
685        assert!(json.get("response_format").is_none());
686        assert!(json.get("watermark_enabled").is_none());
687    }
688
689    #[test]
690    fn audio_exposes_watermark_and_enforces_its_token_limit() {
691        let body = ChatBody::new(GLM4_voice {}, VoiceMessage::new_user())
692            .with_max_tokens(4_096)
693            .with_watermark_enabled(false);
694        assert!(body.validate().is_ok());
695        assert!(body.clone().with_max_tokens(4_097).validate().is_err());
696
697        let json = serde_json::to_value(body).unwrap();
698        assert_eq!(json["watermark_enabled"], false);
699        for field in [
700            "thinking",
701            "reasoning_effort",
702            "tools",
703            "tool_choice",
704            "tool_stream",
705            "response_format",
706        ] {
707            assert!(json.get(field).is_none(), "unexpected audio field: {field}");
708        }
709    }
710
711    #[test]
712    fn debug_redacts_conversation_and_caller_identifiers() {
713        let body = ChatBody::new(GLM5_2 {}, TextMessage::user("prompt-secret-7cc8fd03"))
714            .with_request_id("request-secret-7cc8fd03")
715            .with_user_id("user-secret-7cc8fd03")
716            .with_stop("stop-secret-7cc8fd03")
717            .with_temperature(0.4);
718
719        let debug = format!("{body:?}");
720        for secret in [
721            "prompt-secret-7cc8fd03",
722            "request-secret-7cc8fd03",
723            "user-secret-7cc8fd03",
724            "stop-secret-7cc8fd03",
725        ] {
726            assert!(!debug.contains(secret));
727        }
728        assert!(debug.contains("GLM5_2"));
729        assert!(debug.contains("messages_count: 1"));
730        assert!(debug.contains("request_id_set: true"));
731        assert!(debug.contains("user_id_set: true"));
732        assert!(debug.contains("stop_count: Some(1)"));
733        assert!(debug.contains("temperature: Some(0.4)"));
734    }
735
736    #[test]
737    fn validation_rejects_invalid_nested_tools() {
738        let body: ChatBody<GLM4_6, TextMessage> = ChatBody::new(GLM4_6 {}, TextMessage::user("hi"))
739            .add_tool(Tools::Function {
740                function: Function::new("", "invalid", serde_json::json!({"type": "object"})),
741            });
742        assert!(body.validate().is_err());
743    }
744
745    #[test]
746    fn validation_requires_tools_when_auto_choice_is_explicit() {
747        let body: ChatBody<GLM4_6, TextMessage> =
748            ChatBody::new(GLM4_6 {}, TextMessage::user("hi")).with_tool_choice(ToolChoice::auto());
749        assert!(body.validate().is_err());
750
751        let function = Function::new("found", "valid", serde_json::json!({"type": "object"}));
752        let body = body
753            .add_tool(Tools::Function { function })
754            .with_tool_choice(ToolChoice::auto());
755        assert!(body.validate().is_ok());
756
757        let json = serde_json::to_value(body.clear_tools()).unwrap();
758        assert!(json.get("tools").is_none());
759        assert!(json.get("tool_choice").is_none());
760    }
761}