Skip to main content

zai_rs/model/
chat_base_request.rs

1//! # Chat Request Body
2//!
3//! Provides the core [`ChatBody`] structure shared by all chat-completion
4//! endpoints. The generic type parameters enforce compile-time compatibility
5//! between model and message types.
6//!
7//! # Type Parameters
8//!
9//! - `N` — Model identifier type (must implement
10//!   [`ModelName`](super::traits::ModelName))
11//! - `M` — Message type (must form a [`Bounded`](super::traits::Bounded) pair
12//!   with `N`)
13
14use serde::Serialize;
15use validator::*;
16
17use super::{tools::*, traits::*};
18
19/// Main request body structure for chat API calls.
20///
21/// This structure represents a complete chat request with all possible
22/// configuration options. It uses generic types to support different model
23/// names and message types while maintaining type safety through trait bounds.
24///
25/// # Type Parameters
26///
27/// * `N` - The model name type, must implement [`ModelName`]
28/// * `M` - The message type, must form a [`Bounded`] pair with `N`
29///
30/// # Examples
31///
32/// ```rust,ignore
33/// use crate::model::base::{ChatBody, TextMessage};
34///
35/// // Create a basic chat request
36/// let chat_body = ChatBody {
37///     model: "gpt-4".to_string(),
38///     messages: vec![
39///         TextMessage::user("Hello, how are you?"),
40///         TextMessage::assistant("I'm doing well, thank you!")
41///     ],
42///     temperature: Some(0.7),
43///     max_tokens: Some(1000),
44///     ..Default::default()
45/// };
46/// ```
47#[derive(Debug, Clone, Validate, Serialize)]
48pub struct ChatBody<N, M>
49where
50    N: ModelName,
51    (N, M): Bounded,
52{
53    /// The model to use for the chat completion.
54    pub model: N,
55
56    /// A list of messages comprising the conversation so far.
57    pub messages: Vec<M>,
58
59    /// A unique identifier for the request. Optional field that will be omitted
60    /// from serialization if not provided.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub request_id: Option<String>,
63
64    /// Optional thinking prompt or reasoning text that can guide the model's
65    /// response. Only available for models that support thinking
66    /// capabilities.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub thinking: Option<ThinkingType>,
69
70    /// Controls the depth of reasoning when thinking mode is enabled. Only
71    /// available for GLM-5.2 and above (models that implement
72    /// [`ReasoningEffortEnable`]). See [`ReasoningEffort`] for the available
73    /// levels.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub reasoning_effort: Option<ReasoningEffort>,
76
77    /// Whether to use sampling during generation. When `true`, the model will
78    /// use probabilistic sampling; when `false`, it will use deterministic
79    /// generation.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub do_sample: Option<bool>,
82
83    /// Whether to stream back partial message deltas as they are generated.
84    /// When `true`, responses will be sent as server-sent events.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub stream: Option<bool>,
87
88    /// Whether to enable streaming of tool calls (streaming function call
89    /// parameters). Supported by GLM-5.2, GLM-5.1, GLM-5, GLM-5-Turbo, GLM-4.7,
90    /// and GLM-4.6 models. Defaults to false when omitted.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub tool_stream: Option<bool>,
93
94    /// Controls randomness in the output. Higher values (closer to 1.0) make
95    /// the output more random, while lower values (closer to 0.0) make it
96    /// more deterministic. Must be between 0.0 and 1.0.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    #[validate(range(min = 0.0, max = 1.0))]
99    pub temperature: Option<f64>,
100
101    /// Controls diversity via nucleus sampling. Only tokens with cumulative
102    /// probability up to `top_p` are considered. Must be between 0.0 and
103    /// 1.0.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    #[validate(range(min = 0.0, max = 1.0))]
106    pub top_p: Option<f64>,
107
108    /// The maximum number of tokens to generate in the completion.
109    /// Must be between 1 and 98304.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    #[validate(range(min = 1, max = 98304))]
112    pub max_tokens: Option<u32>,
113
114    /// A list of tools the model may call. Currently supports function calling,
115    /// web search, and retrieval tools.
116    /// Note: server expects an array; we model this as a vector of tool items.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub tools: Option<Vec<Tools>>,
119
120    // tool_choice: enum<string>, but we don't need it for now
121    /// A unique identifier representing your end-user, which can help monitor
122    /// and detect abuse. Must be between 6 and 128 characters long.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    #[validate(length(min = 6, max = 128))]
125    pub user_id: Option<String>,
126
127    /// Up to 1 sequence where the API will stop generating further tokens.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    #[validate(length(min = 1, max = 1))]
130    pub stop: Option<Vec<String>>,
131
132    /// An object specifying the format that the model must output.
133    /// Can be either text or JSON object format.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub response_format: Option<ResponseFormat>,
136}
137
138impl<N, M> ChatBody<N, M>
139where
140    N: ModelName,
141    (N, M): Bounded,
142{
143    pub fn new(model: N, messages: M) -> Self {
144        Self {
145            model,
146            messages: vec![messages],
147            request_id: None,
148            thinking: None,
149            reasoning_effort: None,
150            do_sample: None,
151            stream: None,
152            tool_stream: None,
153            temperature: None,
154            top_p: None,
155            max_tokens: None,
156            tools: None,
157            user_id: None,
158            stop: None,
159            response_format: None,
160        }
161    }
162
163    pub fn add_messages(mut self, messages: M) -> Self {
164        self.messages.push(messages);
165        self
166    }
167    /// Add a single message to the conversation (preferred over add_messages
168    /// for clarity when adding one message).
169    pub fn add_message(mut self, message: M) -> Self {
170        self.messages.push(message);
171        self
172    }
173    /// Add multiple messages to the conversation at once.
174    pub fn extend_messages(mut self, messages: impl IntoIterator<Item = M>) -> Self {
175        self.messages.extend(messages);
176        self
177    }
178    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
179        self.request_id = Some(request_id.into());
180        self
181    }
182    pub fn with_do_sample(mut self, do_sample: bool) -> Self {
183        self.do_sample = Some(do_sample);
184        self
185    }
186    pub fn with_stream(mut self, stream: bool) -> Self {
187        self.stream = Some(stream);
188        self
189    }
190    pub fn with_temperature(mut self, temperature: f64) -> Self {
191        self.temperature = Some(temperature);
192        self
193    }
194    pub fn with_top_p(mut self, top_p: f64) -> Self {
195        self.top_p = Some(top_p);
196        self
197    }
198    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
199        self.max_tokens = Some(max_tokens);
200        self
201    }
202    /// Deprecated: use `add_tools` (single) or `extend_tools` (Vec) on
203    /// ChatBody, or prefer ChatCompletion::add_tool / add_tools at the
204    /// client layer.
205    #[deprecated(note = "with_tools is deprecated; use add_tool/add_tools instead")]
206    pub fn with_tools(mut self, tools: impl Into<Vec<Tools>>) -> Self {
207        self.tools = Some(tools.into());
208        self
209    }
210    pub fn add_tools(mut self, tools: Tools) -> Self {
211        self.tools.get_or_insert(Vec::new()).push(tools);
212        self
213    }
214    pub fn extend_tools(mut self, tools: Vec<Tools>) -> Self {
215        self.tools.get_or_insert(Vec::new()).extend(tools);
216        self
217    }
218    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
219        self.user_id = Some(user_id.into());
220        self
221    }
222    pub fn with_stop(mut self, stop: String) -> Self {
223        self.stop.get_or_insert_with(Vec::new).push(stop);
224        self
225    }
226}
227
228impl<N, M> ChatBody<N, M>
229where
230    N: ModelName + ThinkEnable,
231    (N, M): Bounded,
232{
233    /// Adds thinking text to the chat body for models that support thinking
234    /// capabilities.
235    ///
236    /// This method is only available for models that implement the
237    /// [`ThinkEnable`] trait, ensuring type safety for thinking-enabled
238    /// models.
239    ///
240    /// # Arguments
241    ///
242    /// * `thinking` - The thinking prompt or reasoning text to add
243    ///
244    /// # Returns
245    ///
246    /// Returns `self` with the thinking field set, allowing for method
247    /// chaining.
248    ///
249    /// # Examples
250    ///
251    /// ```rust,ignore
252    /// let chat_body = ChatBody::new(model, messages)
253    ///     .with_thinking("Let me think step by step about this problem...");
254    /// ```
255    pub fn with_thinking(mut self, thinking: ThinkingType) -> Self {
256        self.thinking = Some(thinking);
257        self
258    }
259}
260
261// Only available for models that support the reasoning_effort parameter
262// (GLM-5.2 and above).
263impl<N, M> ChatBody<N, M>
264where
265    N: ModelName + ReasoningEffortEnable,
266    (N, M): Bounded,
267{
268    /// Sets the `reasoning_effort` level that controls how much reasoning the
269    /// model performs when thinking mode is enabled.
270    ///
271    /// Only available on GLM-5.2 and above (models implementing
272    /// [`ReasoningEffortEnable`]). Typically combined with
273    /// [`with_thinking`](Self::with_thinking) to enable thinking first.
274    ///
275    /// # Examples
276    ///
277    /// ```rust,ignore
278    /// use zai_rs::model::tools::ReasoningEffort;
279    ///
280    /// let chat_body = ChatBody::new(GLM5_2 {}, messages)
281    ///     .with_thinking(ThinkingType::enabled())
282    ///     .with_reasoning_effort(ReasoningEffort::Max);
283    /// ```
284    pub fn with_reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
285        self.reasoning_effort = Some(effort);
286        self
287    }
288}
289
290// Only available when the model supports streaming tool calls (GLM-4.6)
291impl<N, M> ChatBody<N, M>
292where
293    N: ModelName + ToolStreamEnable,
294    (N, M): Bounded,
295{
296    /// Enables streaming tool calls. Supported by GLM-5.2, GLM-5.1, GLM-5,
297    /// GLM-5-Turbo, GLM-4.7, and GLM-4.6 models. Default is false when omitted.
298    pub fn with_tool_stream(mut self, tool_stream: bool) -> Self {
299        self.tool_stream = Some(tool_stream);
300        if tool_stream {
301            // Enabling tool_stream implies stream=true
302            self.stream = Some(true);
303        }
304        self
305    }
306}
307
308// 为方便使用,实现从单个Tools到Vec<Tools>的转换
309impl From<Tools> for Vec<Tools> {
310    fn from(tool: Tools) -> Self {
311        vec![tool]
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::model::{
319        chat_message_types::TextMessage,
320        chat_models::{GLM4_5, GLM4_6, GLM5_2},
321    };
322
323    #[test]
324    fn test_with_tool_stream_sets_both_fields() {
325        let body: ChatBody<GLM4_6, TextMessage> =
326            ChatBody::new(GLM4_6 {}, TextMessage::user("test"));
327        let body = body.with_tool_stream(true);
328        assert_eq!(body.tool_stream, Some(true));
329        assert_eq!(body.stream, Some(true));
330    }
331
332    #[test]
333    fn test_with_tool_stream_false_does_not_force_stream() {
334        let body: ChatBody<GLM4_6, TextMessage> =
335            ChatBody::new(GLM4_6 {}, TextMessage::user("test"));
336        let body = body.with_tool_stream(false);
337        assert_eq!(body.tool_stream, Some(false));
338        // stream should not be forced to true when tool_stream is false
339        assert_ne!(body.stream, Some(true));
340    }
341
342    #[test]
343    fn test_add_tools_accumulates() {
344        let body: ChatBody<GLM4_6, TextMessage> =
345            ChatBody::new(GLM4_6 {}, TextMessage::user("test"));
346        let tool = crate::model::tools::Function::new(
347            "test_fn",
348            "A test function",
349            serde_json::json!({"type": "object"}),
350        );
351        let body = body.add_tools(crate::model::tools::Tools::Function { function: tool });
352        assert!(body.tools.is_some());
353        assert_eq!(body.tools.unwrap().len(), 1);
354    }
355
356    #[test]
357    fn test_extend_messages() {
358        let body: ChatBody<GLM4_6, TextMessage> =
359            ChatBody::new(GLM4_6 {}, TextMessage::user("first"));
360        let body = body.extend_messages(vec![
361            TextMessage::assistant("second"),
362            TextMessage::user("third"),
363        ]);
364        assert_eq!(body.messages.len(), 3);
365        match &body.messages[0] {
366            TextMessage::User { content } => assert_eq!(content, "first"),
367            _ => panic!("Expected User message"),
368        }
369    }
370
371    #[test]
372    fn test_add_message() {
373        let body: ChatBody<GLM4_6, TextMessage> =
374            ChatBody::new(GLM4_6 {}, TextMessage::user("first"));
375        let body = body.add_message(TextMessage::assistant("second"));
376        assert_eq!(body.messages.len(), 2);
377    }
378
379    #[test]
380    fn test_glm52_reasoning_effort_builder() {
381        // reasoning_effort is only available on GLM-5.2+ (ReasoningEffortEnable)
382        let body: ChatBody<GLM5_2, TextMessage> = ChatBody::new(GLM5_2 {}, TextMessage::user("hi"))
383            .with_thinking(ThinkingType::enabled())
384            .with_reasoning_effort(ReasoningEffort::Max);
385        assert_eq!(body.reasoning_effort, Some(ReasoningEffort::Max));
386        assert!(body.thinking.is_some());
387    }
388
389    #[test]
390    fn test_glm52_serializes_model_name() {
391        let body: ChatBody<GLM5_2, TextMessage> = ChatBody::new(GLM5_2 {}, TextMessage::user("hi"));
392        let json = serde_json::to_value(&body).unwrap();
393        assert_eq!(json["model"], "glm-5.2");
394        // reasoning_effort must be omitted when not set
395        assert!(json.get("reasoning_effort").is_none());
396    }
397
398    #[test]
399    fn test_sampling_parameters_serialize_without_f32_expansion() {
400        let body: ChatBody<GLM4_5, TextMessage> = ChatBody::new(GLM4_5 {}, TextMessage::user("hi"))
401            .with_temperature(0.7)
402            .with_top_p(0.9);
403
404        let json = serde_json::to_value(&body).unwrap();
405        assert_eq!(json["temperature"], serde_json::json!(0.7));
406        assert_eq!(json["top_p"], serde_json::json!(0.9));
407
408        let serialized = serde_json::to_string(&body).unwrap();
409        assert!(serialized.contains("\"temperature\":0.7"));
410        assert!(serialized.contains("\"top_p\":0.9"));
411        assert!(!serialized.contains("0.699999"));
412        assert!(!serialized.contains("0.899999"));
413    }
414
415    #[test]
416    fn test_reasoning_effort_serializes_level() {
417        let body: ChatBody<GLM5_2, TextMessage> = ChatBody::new(GLM5_2 {}, TextMessage::user("hi"))
418            .with_reasoning_effort(ReasoningEffort::Xhigh);
419        let json = serde_json::to_value(&body).unwrap();
420        assert_eq!(json["reasoning_effort"], "xhigh");
421    }
422}