1use serde::Serialize;
8use validator::*;
9
10use super::{tools::*, traits::*};
11
12#[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 model: N,
42
43 #[validate(length(min = 1))]
45 messages: Vec<M>,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
50 #[validate(length(min = 6, max = 64))]
51 request_id: Option<String>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 thinking: Option<ThinkingType>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
62 reasoning_effort: Option<ReasoningEffort>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
68 do_sample: Option<bool>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
73 stream: Option<bool>,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
79 tool_stream: Option<bool>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
85 #[validate(range(min = 0.0, max = 1.0))]
86 temperature: Option<f64>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
92 #[validate(range(min = 0.01, max = 1.0))]
93 top_p: Option<f64>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
99 #[validate(range(min = 1, max = 131072))]
100 max_tokens: Option<u32>,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
106 #[validate(length(max = 128))]
107 tools: Option<Vec<Tools>>,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
113 tool_choice: Option<ToolChoice>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
118 #[validate(length(min = 6, max = 128))]
119 user_id: Option<String>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
123 #[validate(length(min = 1, max = 4))]
124 stop: Option<Vec<String>>,
125
126 #[serde(skip_serializing_if = "Option::is_none")]
129 response_format: Option<ResponseFormat>,
130
131 #[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 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 pub fn add_message(mut self, message: M) -> Self {
238 self.messages.push(message);
239 self
240 }
241 pub fn extend_messages(mut self, messages: impl IntoIterator<Item = M>) -> Self {
243 self.messages.extend(messages);
244 self
245 }
246 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 pub fn with_do_sample(mut self, do_sample: bool) -> Self {
253 self.do_sample = Some(do_sample);
254 self
255 }
256 pub fn with_temperature(mut self, temperature: f64) -> Self {
258 self.temperature = Some(temperature);
259 self
260 }
261 pub fn with_top_p(mut self, top_p: f64) -> Self {
263 self.top_p = Some(top_p);
264 self
265 }
266 pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
268 self.max_tokens = Some(max_tokens);
269 self
270 }
271 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 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 pub const fn model(&self) -> &N {
284 &self.model
285 }
286
287 pub fn messages(&self) -> &[M] {
289 &self.messages
290 }
291
292 pub fn request_id(&self) -> Option<&str> {
294 self.request_id.as_deref()
295 }
296
297 pub const fn thinking(&self) -> Option<&ThinkingType> {
299 self.thinking.as_ref()
300 }
301
302 pub const fn reasoning_effort(&self) -> Option<ReasoningEffort> {
304 self.reasoning_effort
305 }
306
307 pub const fn do_sample(&self) -> Option<bool> {
309 self.do_sample
310 }
311
312 pub const fn stream(&self) -> Option<bool> {
315 self.stream
316 }
317
318 pub const fn tool_stream(&self) -> Option<bool> {
320 self.tool_stream
321 }
322
323 pub const fn temperature(&self) -> Option<f64> {
325 self.temperature
326 }
327
328 pub const fn top_p(&self) -> Option<f64> {
330 self.top_p
331 }
332
333 pub const fn max_tokens(&self) -> Option<u32> {
335 self.max_tokens
336 }
337
338 pub fn tools(&self) -> Option<&[Tools]> {
340 self.tools.as_deref()
341 }
342
343 pub const fn tool_choice(&self) -> Option<ToolChoice> {
345 self.tool_choice
346 }
347
348 pub fn user_id(&self) -> Option<&str> {
350 self.user_id.as_deref()
351 }
352
353 pub fn stop(&self) -> Option<&[String]> {
355 self.stop.as_deref()
356 }
357
358 pub const fn response_format(&self) -> Option<ResponseFormat> {
360 self.response_format
361 }
362
363 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 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 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 pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
399 self.tool_choice = Some(tool_choice);
400 self
401 }
402
403 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 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 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 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 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 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 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 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 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}