1use super::*;
5
6use crate::{OAIChatLikeRequest, TextInput};
7use minijinja::{context, value::Value};
8use std::result::Result::Ok;
9
10pub fn may_be_fix_tool_schema(tools: serde_json::Value) -> Option<Value> {
14 let mut updated_tools = Vec::new();
18 if let Some(arr) = tools.as_array() {
19 for tool in arr {
20 let mut tool = tool.clone();
21 if let Some(function) = tool.get_mut("function")
22 && let Some(parameters) = function.get_mut("parameters")
23 {
24 if parameters.is_object() {
26 let mut needs_type = false;
27 let mut needs_properties = false;
28 let is_empty = parameters
29 .as_object()
30 .map(|o| o.is_empty())
31 .unwrap_or(false);
32
33 if is_empty {
35 needs_type = true;
36 needs_properties = true;
37 } else {
38 if let Some(obj) = parameters.as_object() {
40 if !obj.contains_key("type") {
41 needs_type = true;
42 }
43 if !obj.contains_key("properties") {
44 needs_properties = true;
45 }
46 }
47 }
48
49 if (needs_type || needs_properties)
50 && let Some(obj) = parameters.as_object_mut()
51 {
52 if needs_type {
53 obj.insert(
54 "type".to_string(),
55 serde_json::Value::String("object".to_string()),
56 );
57 }
58 if needs_properties {
59 obj.insert(
60 "properties".to_string(),
61 serde_json::Value::Object(Default::default()),
62 );
63 }
64 }
65 }
66 }
67 updated_tools.push(tool);
68 }
69 }
70 Some(Value::from_serialize(&updated_tools))
71}
72
73const DEFAULT_MEDIA_TYPE_CONVERSIONS: &[(&str, &str)] = &[
76 ("image_url", "image"),
77 ("video_url", "video"),
78 ("audio_url", "audio"),
79];
80
81fn convert_media_url_to_placeholder(
83 content_array: &[serde_json::Value],
84 conversions: &[(&str, &str)],
85) -> Vec<serde_json::Value> {
86 content_array
87 .iter()
88 .map(|part| {
89 let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
90
91 if let Some((_, target_type)) = conversions.iter().find(|(src, _)| *src == part_type) {
92 serde_json::json!({"type": target_type})
93 } else {
94 part.clone()
95 }
96 })
97 .collect()
98}
99
100fn may_be_fix_msg_content(
101 messages: serde_json::Value,
102 preserve_arrays: bool,
103 image_placeholder_template: Option<&str>,
104) -> Value {
105 let Some(arr) = messages.as_array() else {
114 return Value::from_serialize(&messages);
115 };
116
117 let updated_messages: Vec<_> = arr
118 .iter()
119 .map(|msg| {
120 match msg.get("content") {
121 Some(serde_json::Value::String(text)) if preserve_arrays => {
123 let mut modified_msg = msg.clone();
124 if let Some(msg_object) = modified_msg.as_object_mut() {
125 let content_array = serde_json::json!([{
126 "type": "text",
127 "text": text
128 }]);
129 msg_object.insert("content".to_string(), content_array);
130 }
131 modified_msg
132 }
133 Some(serde_json::Value::Array(content_array)) => {
135 let content_array = convert_media_url_to_placeholder(
137 content_array,
138 DEFAULT_MEDIA_TYPE_CONVERSIONS,
139 );
140
141 let is_text_only_array = !content_array.is_empty()
143 && content_array.iter().all(|part| {
144 part.get("type")
145 .and_then(|type_field| type_field.as_str())
146 .map(|type_str| type_str == "text")
147 .unwrap_or(false)
148 });
149
150 let mut modified_msg = msg.clone();
151 if let Some(msg_object) = modified_msg.as_object_mut() {
152 if is_text_only_array && !preserve_arrays {
153 let text_parts: Vec<&str> = content_array
155 .iter()
156 .filter_map(|part| part.get("text")?.as_str())
157 .collect();
158 let concatenated_text = text_parts.join("\n");
159 msg_object.insert(
160 "content".to_string(),
161 serde_json::Value::String(concatenated_text),
162 );
163 } else if !preserve_arrays
164 && !content_array.is_empty()
165 && let Some(placeholder_tpl) = image_placeholder_template
166 {
167 let flattened = flatten_mixed_content(&content_array, placeholder_tpl);
175 msg_object.insert(
176 "content".to_string(),
177 serde_json::Value::String(flattened),
178 );
179 } else {
180 msg_object.insert(
182 "content".to_string(),
183 serde_json::Value::Array(content_array),
184 );
185 }
186 }
187 modified_msg
188 }
189 _ => msg.clone(), }
191 })
192 .collect();
193
194 Value::from_serialize(&updated_messages)
195}
196
197fn flatten_mixed_content(parts: &[serde_json::Value], placeholder_tpl: &str) -> String {
217 let mut out = String::new();
218 let mut img_idx: u32 = 1;
219 for part in parts {
220 let type_str = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
221 if type_str == "text" {
222 if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
223 out.push_str(text);
224 }
225 } else if !type_str.is_empty() {
226 let placeholder = placeholder_tpl.replace("{n}", &img_idx.to_string());
227 out.push_str(&placeholder);
228 img_idx += 1;
229 }
230 }
231 out
232}
233
234fn normalize_tool_calls_arguments_in_messages(messages: &mut serde_json::Value) {
235 let Some(msgs) = messages.as_array_mut() else {
240 return;
241 };
242
243 for msg in msgs.iter_mut() {
244 if let Some(tool_calls) = msg.get_mut("tool_calls").and_then(|v| v.as_array_mut()) {
245 for tc in tool_calls {
246 if let Some(function) = tc.get_mut("function").and_then(|v| v.as_object_mut())
247 && let Some(args) = function.get_mut("arguments")
248 && let Some(s) = args.as_str()
249 && let Ok(parsed) = serde_json::from_str(s)
250 {
251 *args = parsed;
252 }
253 }
254 }
255 }
256}
257
258fn normalize_function_call_arguments_in_messages(messages: &mut serde_json::Value) {
259 let Some(msgs) = messages.as_array_mut() else {
264 return;
265 };
266
267 for msg in msgs.iter_mut() {
268 if let Some(function_call) = msg.get_mut("function_call").and_then(|v| v.as_object_mut())
269 && let Some(args) = function_call.get_mut("arguments")
270 && let Some(s) = args.as_str()
271 && let Ok(parsed) = serde_json::from_str(s)
272 {
273 *args = parsed;
274 }
275 }
276}
277
278fn inject_reasoning_content_into_messages(messages: &mut serde_json::Value) {
292 let Some(msgs) = messages.as_array_mut() else {
293 return;
294 };
295
296 for msg in msgs.iter_mut() {
297 if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") {
298 continue;
299 }
300
301 let reasoning = match msg.get("reasoning_content") {
302 Some(serde_json::Value::String(s)) if !s.is_empty() => {
303 format!("<think>{}</think>", s)
304 }
305 Some(serde_json::Value::Array(segments)) => {
306 let mut result = String::new();
307 for seg in segments {
308 if let Some(s) = seg.as_str()
309 && !s.is_empty()
310 {
311 result.push_str("<think>");
312 result.push_str(s);
313 result.push_str("</think>");
314 }
315 }
316 if result.is_empty() {
317 continue;
318 }
319 result
320 }
321 _ => continue,
322 };
323
324 match msg.get("content") {
325 Some(serde_json::Value::String(s)) if !s.is_empty() => {
327 msg["content"] = serde_json::Value::String(format!("{}{}", reasoning, s));
328 }
329 None | Some(serde_json::Value::Null) | Some(serde_json::Value::String(_)) => {
330 msg["content"] = serde_json::Value::String(reasoning);
331 }
332 Some(serde_json::Value::Array(_)) => {
334 let think_part = serde_json::json!({
335 "type": "text",
336 "text": reasoning
337 });
338 if let Some(arr) = msg.get_mut("content").and_then(|v| v.as_array_mut()) {
339 arr.insert(0, think_part);
340 }
341 }
342 _ => continue,
344 }
345
346 if let Some(obj) = msg.as_object_mut() {
349 obj.remove("reasoning_content");
350 }
351 }
352}
353
354impl OAIChatLikeRequest for dynamo_protocols::types::CreateChatCompletionRequest {
360 fn model(&self) -> String {
361 self.model.clone()
362 }
363
364 fn messages(&self) -> Value {
365 let messages_json = serde_json::to_value(&self.messages).unwrap();
366 Value::from_serialize(&messages_json)
367 }
368
369 fn typed_messages(&self) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
370 Some(self.messages.as_slice())
371 }
372
373 fn tools(&self) -> Option<Value> {
374 if self.tools.is_none() {
375 None
376 } else {
377 Some(may_be_fix_tool_schema(
378 serde_json::to_value(&self.tools).unwrap(),
379 )?)
380 }
381 }
382
383 fn tool_choice(&self) -> Option<Value> {
384 if self.tool_choice.is_none() {
385 None
386 } else {
387 Some(Value::from_serialize(&self.tool_choice))
388 }
389 }
390
391 fn response_format(&self) -> Option<Value> {
392 self.response_format.as_ref().map(Value::from_serialize)
393 }
394
395 fn should_add_generation_prompt(&self) -> bool {
396 true
398 }
399
400 fn extract_text(&self) -> Option<TextInput> {
401 Some(TextInput::Single(String::new()))
402 }
403
404 fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
405 self.mm_processor_kwargs.as_ref()
406 }
407}
408
409impl OAIPromptFormatter for HfTokenizerConfigJsonFormatter {
410 fn supports_add_generation_prompt(&self) -> bool {
411 self.supports_add_generation_prompt
412 }
413
414 fn image_placeholder_template(&self) -> Option<&'static str> {
415 self.image_placeholder_template
416 }
417
418 fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
419 let mixins = Value::from_dyn_object(self.mixins.clone());
420
421 let tools = req.tools();
422 let tools = if self.exclude_tools_when_tool_choice_none {
425 match req.tool_choice() {
426 Some(ref tc) if tc.as_str() == Some("none") => None,
427 _ => tools,
428 }
429 } else {
430 tools
431 };
432 let has_tools = tools.as_ref().and_then(|v| v.len()).is_some_and(|l| l > 0);
434 let add_generation_prompt = req.should_add_generation_prompt();
435
436 tracing::trace!(
437 "Rendering prompt with tools: {:?}, add_generation_prompt: {}",
438 has_tools,
439 add_generation_prompt
440 );
441
442 let messages_canonical = req.messages();
443 let mut messages_for_template: serde_json::Value =
444 serde_json::to_value(&messages_canonical).unwrap();
445
446 messages_for_template = serde_json::to_value(may_be_fix_msg_content(
447 messages_for_template,
448 self.requires_content_arrays,
449 self.image_placeholder_template,
450 ))
451 .unwrap();
452
453 let (template_name, template_handles_tool_calls_args_string) = if has_tools {
460 (
461 "tool_use",
462 self.tool_use_template_handles_tool_calls_arguments_string,
463 )
464 } else {
465 (
466 "default",
467 self.default_template_handles_tool_calls_arguments_string,
468 )
469 };
470
471 if !template_handles_tool_calls_args_string {
478 normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
479 }
480 normalize_function_call_arguments_in_messages(&mut messages_for_template);
484
485 if !self.template_handles_reasoning {
490 inject_reasoning_content_into_messages(&mut messages_for_template);
491 }
492
493 let ctx = context! {
494 messages => messages_for_template,
495 tools => tools,
496 bos_token => self.config.bos_tok(),
497 eos_token => self.config.eos_tok(),
498 unk_token => self.config.unk_tok(),
499 add_generation_prompt => add_generation_prompt,
500 ..mixins
501 };
502
503 let ctx = if let Some(args) = req.chat_template_args() {
505 let extra = Value::from_serialize(args);
506 context! { ..ctx, ..extra }
507 } else {
508 ctx
509 };
510
511 let tmpl: minijinja::Template<'_, '_> = self.env.get_template(template_name)?;
512 Ok(tmpl.render(&ctx)?)
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use dynamo_protocols::types::ChatCompletionRequestMessage as Msg;
520 use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
523 use minijinja::{Environment, context};
524
525 #[test]
527 fn test_convert_media_url_to_placeholder_single_type() {
528 let content_array = vec![
529 serde_json::json!({"type": "text", "text": "Check this image:"}),
530 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
531 serde_json::json!({"type": "text", "text": "What do you see?"}),
532 ];
533
534 let conversions = &[("image_url", "image")];
535 let result = convert_media_url_to_placeholder(&content_array, conversions);
536
537 assert_eq!(result.len(), 3);
538 assert_eq!(result[0]["type"], "text");
540 assert_eq!(result[0]["text"], "Check this image:");
541 assert_eq!(result[1]["type"], "image");
543 assert!(result[1].get("image_url").is_none());
544 assert_eq!(result[2]["type"], "text");
546 assert_eq!(result[2]["text"], "What do you see?");
547 }
548
549 #[test]
551 fn test_convert_media_url_to_placeholder_multiple_same_type() {
552 let content_array = vec![
553 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}}),
554 serde_json::json!({"type": "text", "text": "vs"}),
555 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}),
556 ];
557
558 let conversions = &[("image_url", "image")];
559 let result = convert_media_url_to_placeholder(&content_array, conversions);
560
561 assert_eq!(result.len(), 3);
562 assert_eq!(result[0]["type"], "image");
563 assert_eq!(result[1]["type"], "text");
564 assert_eq!(result[2]["type"], "image");
565 }
566
567 #[test]
569 fn test_convert_media_url_to_placeholder_selective_conversion() {
570 let content_array = vec![
571 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
572 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
573 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
574 ];
575
576 let conversions = &[("image_url", "image")];
578 let result = convert_media_url_to_placeholder(&content_array, conversions);
579
580 assert_eq!(result.len(), 3);
581 assert_eq!(result[0]["type"], "audio_url");
583 assert!(result[0].get("audio_url").is_some());
584 assert_eq!(result[1]["type"], "video_url");
585 assert!(result[1].get("video_url").is_some());
586 assert_eq!(result[2]["type"], "image");
588 assert!(result[2].get("image_url").is_none());
589 }
590
591 #[test]
593 fn test_convert_media_url_to_placeholder_multiple_types() {
594 let content_array = vec![
595 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
596 serde_json::json!({"type": "text", "text": "and listen to"}),
597 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
598 serde_json::json!({"type": "text", "text": "and watch"}),
599 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
600 ];
601
602 let conversions = &[
604 ("image_url", "image"),
605 ("audio_url", "audio"),
606 ("video_url", "video"),
607 ];
608 let result = convert_media_url_to_placeholder(&content_array, conversions);
609
610 assert_eq!(result.len(), 5);
611 assert_eq!(result[0]["type"], "image");
612 assert!(result[0].get("image_url").is_none());
613 assert_eq!(result[1]["type"], "text");
614 assert_eq!(result[2]["type"], "audio");
615 assert!(result[2].get("audio_url").is_none());
616 assert_eq!(result[3]["type"], "text");
617 assert_eq!(result[4]["type"], "video");
618 assert!(result[4].get("video_url").is_none());
619 }
620
621 #[test]
623 fn test_convert_media_url_to_placeholder_no_conversions() {
624 let content_array = vec![
625 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
626 serde_json::json!({"type": "text", "text": "hello"}),
627 ];
628
629 let conversions: &[(&str, &str)] = &[];
630 let result = convert_media_url_to_placeholder(&content_array, conversions);
631
632 assert_eq!(result.len(), 2);
633 assert_eq!(result[0]["type"], "image_url");
635 assert!(result[0].get("image_url").is_some());
636 assert_eq!(result[1]["type"], "text");
637 }
638
639 #[test]
642 fn test_default_media_type_conversions_only_converts_image_url() {
643 let content_array = vec![
644 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
645 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
646 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
647 serde_json::json!({"type": "text", "text": "hello"}),
648 ];
649
650 let result =
652 convert_media_url_to_placeholder(&content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
653
654 assert_eq!(result.len(), 4);
655
656 assert_eq!(result[0]["type"], "image");
658 assert!(result[0].get("image_url").is_none());
659
660 assert_eq!(result[1]["type"], "video");
662 assert!(result[1].get("video_url").is_none());
663
664 assert_eq!(result[2]["type"], "audio");
666 assert!(result[2].get("audio_url").is_none());
667
668 assert_eq!(result[3]["type"], "text");
670 assert_eq!(result[3]["text"], "hello");
671 }
672
673 #[test]
674 fn test_may_be_fix_tool_schema_missing_type_and_properties() {
675 let json_str = r#"{
676 "model": "gpt-4o",
677 "messages": [],
678 "tools": [
679 {
680 "type": "function",
681 "function": {
682 "name": "get_weather",
683 "description": "Get the current weather in a given location",
684 "parameters": {},
685 "strict": null
686 }
687 }
688 ]
689 }"#;
690
691 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
692 let tools = serde_json::to_value(request.tools()).unwrap();
693
694 assert!(tools[0]["function"]["parameters"]["type"] == "object");
695 assert!(
696 tools[0]["function"]["parameters"]["properties"]
697 == serde_json::Value::Object(Default::default())
698 );
699 }
700
701 #[test]
702 fn test_may_be_fix_tool_schema_missing_type() {
703 let json_str = r#"{
704 "model": "gpt-4o",
705 "messages": [],
706 "tools": [
707 {
708 "type": "function",
709 "function": {
710 "name": "get_weather",
711 "description": "Get the current weather in a given location",
712 "parameters": {
713 "properties": {
714 "location": {
715 "type": "string",
716 "description": "City and state, e.g., 'San Francisco, CA'"
717 }
718 }
719 },
720 "strict": null
721 }
722 }
723 ]
724 }"#;
725 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
726
727 let tools = serde_json::to_value(request.tools()).unwrap();
728
729 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
730
731 let mut expected_properties = serde_json::Map::new();
732 let mut location = serde_json::Map::new();
733 location.insert(
734 "type".to_string(),
735 serde_json::Value::String("string".to_string()),
736 );
737 location.insert(
738 "description".to_string(),
739 serde_json::Value::String("City and state, e.g., 'San Francisco, CA'".to_string()),
740 );
741 expected_properties.insert("location".to_string(), serde_json::Value::Object(location));
742
743 assert_eq!(
744 tools[0]["function"]["parameters"]["properties"],
745 serde_json::Value::Object(expected_properties)
746 );
747 }
748
749 #[test]
750 fn test_may_be_fix_tool_schema_missing_properties() {
751 let json_str = r#"{
752 "model": "gpt-4o",
753 "messages": [],
754 "tools": [
755 {
756 "type": "function",
757 "function": {
758 "name": "get_weather",
759 "description": "Get the current weather in a given location",
760 "parameters": {"type": "object"},
761 "strict": null
762 }
763 }
764 ]
765 }"#;
766
767 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
768 let tools = serde_json::to_value(request.tools()).unwrap();
769
770 assert_eq!(
771 tools[0]["function"]["parameters"]["properties"],
772 serde_json::Value::Object(Default::default())
773 );
774 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
775 }
776
777 #[test]
779 fn test_may_be_fix_msg_content_user_multipart() {
780 let json_str = r#"{
781 "model": "gpt-4o",
782 "messages": [
783 {
784 "role": "user",
785 "content": [
786 {"type": "text", "text": "part 1"},
787 {"type": "text", "text": "part 2"}
788 ]
789 }
790 ]
791 }"#;
792
793 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
794 let messages_raw = serde_json::to_value(request.messages()).unwrap();
795
796 let messages =
798 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
799
800 assert_eq!(
802 messages[0]["content"],
803 serde_json::Value::String("part 1\npart 2".to_string())
804 );
805 }
806
807 #[test]
810 fn test_may_be_fix_msg_content_mixed_messages() {
811 let json_str = r#"{
812 "model": "gpt-4o",
813 "messages": [
814 {
815 "role": "system",
816 "content": "You are a helpful assistant"
817 },
818 {
819 "role": "user",
820 "content": [
821 {"type": "text", "text": "Hello"},
822 {"type": "text", "text": "World"}
823 ]
824 },
825 {
826 "role": "assistant",
827 "content": "Hi there!"
828 },
829 {
830 "role": "user",
831 "content": [
832 {"type": "text", "text": "Another"},
833 {"type": "text", "text": "multi-part"},
834 {"type": "text", "text": "message"}
835 ]
836 }
837 ]
838 }"#;
839
840 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
841 let messages_raw = serde_json::to_value(request.messages()).unwrap();
842
843 let messages =
845 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
846
847 assert_eq!(
849 messages[0]["content"],
850 serde_json::Value::String("You are a helpful assistant".to_string())
851 );
852
853 assert_eq!(
855 messages[1]["content"],
856 serde_json::Value::String("Hello\nWorld".to_string())
857 );
858
859 assert_eq!(
861 messages[2]["content"],
862 serde_json::Value::String("Hi there!".to_string())
863 );
864
865 assert_eq!(
867 messages[3]["content"],
868 serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
869 );
870 }
871
872 #[test]
874 fn test_may_be_fix_msg_content_empty_array() {
875 let json_str = r#"{
876 "model": "gpt-4o",
877 "messages": [
878 {
879 "role": "user",
880 "content": []
881 }
882 ]
883 }"#;
884
885 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
886 let messages_raw = serde_json::to_value(request.messages()).unwrap();
887
888 let messages =
890 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
891
892 assert!(messages[0]["content"].is_array());
894 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
895 }
896
897 #[test]
904 fn test_may_be_fix_msg_content_empty_array_with_placeholder_template() {
905 let json_str = r#"{
906 "model": "phi-3-vision",
907 "messages": [
908 {
909 "role": "user",
910 "content": []
911 }
912 ]
913 }"#;
914
915 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
916 let messages_raw = serde_json::to_value(request.messages()).unwrap();
917
918 let messages = serde_json::to_value(may_be_fix_msg_content(
921 messages_raw,
922 false,
923 Some("<|image_{n}|>"),
924 ))
925 .unwrap();
926
927 assert!(
928 messages[0]["content"].is_array(),
929 "empty array should be preserved as `[]`, not flattened to `\"\"`"
930 );
931 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
932 }
933
934 #[test]
936 fn test_may_be_fix_msg_content_single_text() {
937 let json_str = r#"{
938 "model": "gpt-4o",
939 "messages": [
940 {
941 "role": "user",
942 "content": "Simple text message"
943 }
944 ]
945 }"#;
946
947 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
948 let messages_raw = serde_json::to_value(request.messages()).unwrap();
949
950 let messages =
952 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
953
954 assert_eq!(
956 messages[0]["content"],
957 serde_json::Value::String("Simple text message".to_string())
958 );
959 }
960
961 #[test]
964 fn test_may_be_fix_msg_content_mixed_types() {
965 let json_str = r#"{
966 "model": "gpt-4o",
967 "messages": [
968 {
969 "role": "user",
970 "content": [
971 {"type": "text", "text": "Check this image:"},
972 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
973 {"type": "text", "text": "What do you see?"}
974 ]
975 }
976 ]
977 }"#;
978
979 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
980 let messages_raw = serde_json::to_value(request.messages()).unwrap();
981
982 let messages =
984 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
985
986 assert!(messages[0]["content"].is_array());
989 let content_array = messages[0]["content"].as_array().unwrap();
990 assert_eq!(content_array.len(), 3);
991 assert_eq!(content_array[0]["type"], "text");
992 assert_eq!(content_array[1]["type"], "image");
993 assert!(content_array[1].get("image_url").is_none());
994 assert_eq!(content_array[2]["type"], "text");
995 }
996
997 #[test]
1003 fn test_may_be_fix_msg_content_flattens_phi3_style() {
1004 let json_str = r#"{
1005 "model": "phi-3-vision",
1006 "messages": [
1007 {
1008 "role": "user",
1009 "content": [
1010 {"type": "text", "text": "First "},
1011 {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}},
1012 {"type": "text", "text": " then "},
1013 {"type": "image_url", "image_url": {"url": "https://example.com/b.jpg"}},
1014 {"type": "text", "text": "?"}
1015 ]
1016 }
1017 ]
1018 }"#;
1019 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1020 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1021
1022 let messages = serde_json::to_value(may_be_fix_msg_content(
1023 messages_raw,
1024 false,
1025 Some("<|image_{n}|>"),
1026 ))
1027 .unwrap();
1028
1029 let content = messages[0]["content"].as_str().expect("content flattened");
1030 assert_eq!(content, "First <|image_1|> then <|image_2|>?");
1031 }
1032
1033 #[test]
1035 fn test_may_be_fix_msg_content_flattens_llava_style() {
1036 let json_str = r#"{
1037 "model": "llava-1.5-7b-hf",
1038 "messages": [
1039 {
1040 "role": "user",
1041 "content": [
1042 {"type": "text", "text": "Describe: "},
1043 {"type": "image_url", "image_url": {"url": "https://example.com/x.jpg"}}
1044 ]
1045 }
1046 ]
1047 }"#;
1048 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1049 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1050
1051 let messages =
1052 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some("<image>")))
1053 .unwrap();
1054
1055 let content = messages[0]["content"].as_str().expect("content flattened");
1056 assert_eq!(content, "Describe: <image>");
1057 }
1058
1059 #[test]
1062 fn test_may_be_fix_msg_content_non_text_only() {
1063 let json_str = r#"{
1064 "model": "gpt-4o",
1065 "messages": [
1066 {
1067 "role": "user",
1068 "content": [
1069 {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}},
1070 {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}
1071 ]
1072 }
1073 ]
1074 }"#;
1075
1076 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1077 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1078
1079 let messages =
1081 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1082
1083 assert!(messages[0]["content"].is_array());
1085 let content_array = messages[0]["content"].as_array().unwrap();
1086 assert_eq!(content_array.len(), 2);
1087 assert_eq!(content_array[0]["type"], "image");
1088 assert_eq!(content_array[1]["type"], "image");
1089 }
1090
1091 #[test]
1092 fn test_none_tools_safe_for_all_templates() {
1093 use super::tokcfg::ChatTemplate;
1094 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1095
1096 let length_template = r#"
1100{%- if tools is iterable and tools | length > 0 %}
1101Tools available: {{ tools | length }}
1102{%- else %}
1103No tools
1104{%- endif %}
1105"#;
1106
1107 let no_tool_template = r#"
1110{%- if tools is not none %}
1111TOOL MODE
1112{%- else %}
1113NORMAL MODE
1114{%- endif %}
1115"#;
1116
1117 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1118 "chat_template": [
1119 {"safe_length": length_template},
1120 {"no_tool": no_tool_template}
1121 ]
1122 }))
1123 .unwrap();
1124
1125 let formatter =
1126 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1127
1128 let ctx = context! { tools => Option::<Value>::None };
1129
1130 let result1 = formatter
1131 .env
1132 .get_template("safe_length")
1133 .unwrap()
1134 .render(&ctx);
1135 println!("Safe length template with no tools => None: {:?}", result1);
1136 assert!(
1137 result1.is_ok(),
1138 "Jinja template with and conditional and length filter should handle None: {:?}",
1139 result1
1140 );
1141 assert!(
1142 result1.unwrap().contains("No tools"),
1143 "Should show 'No tools'"
1144 );
1145
1146 let result2 = formatter.env.get_template("no_tool").unwrap().render(&ctx);
1147 println!("Default template with no tools => None: {:?}", result2);
1148 assert!(
1149 result2.is_ok(),
1150 "Jinja template with if tools is not none conditional should handle None: {:?}",
1151 result2
1152 );
1153 assert!(result2.unwrap().contains("NORMAL MODE"));
1154 }
1155
1156 #[test]
1158 fn test_may_be_fix_msg_content_multiple_content_types() {
1159 let json_str = r#"{
1161 "model": "gpt-4o",
1162 "messages": [
1163 {
1164 "role": "user",
1165 "content": [
1166 {"type": "text", "text": "Listen to this:"},
1167 {"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}},
1168 {"type": "text", "text": "And look at:"},
1169 {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
1170 {"type": "text", "text": "What do you think?"}
1171 ]
1172 }
1173 ]
1174 }"#;
1175
1176 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1177 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1178 let messages =
1179 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1180
1181 assert!(messages[0]["content"].is_array());
1183 let content_array = messages[0]["content"].as_array().unwrap();
1184 assert_eq!(content_array.len(), 5);
1185 assert_eq!(content_array[0]["type"], "text");
1186 assert_eq!(content_array[1]["type"], "audio");
1187 assert_eq!(content_array[2]["type"], "text");
1188 assert_eq!(content_array[3]["type"], "image");
1189 assert_eq!(content_array[4]["type"], "text");
1190
1191 let json_str = r#"{
1193 "model": "gpt-4o",
1194 "messages": [
1195 {
1196 "role": "user",
1197 "content": [
1198 {"type": "text", "text": "Check this:"},
1199 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1200 {"type": "text", "text": "Interesting?"}
1201 ]
1202 }
1203 ]
1204 }"#;
1205
1206 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1207 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1208 let messages =
1209 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1210
1211 assert!(messages[0]["content"].is_array());
1213 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1214 }
1215
1216 #[test]
1217 fn test_normalize_tool_arguments_tojson() {
1218 let tmpl = r#"{{ messages[0].tool_calls[0].function.arguments | tojson }}"#;
1219
1220 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1222 "role": "assistant",
1223 "tool_calls": [{
1224 "type": "function",
1225 "function": {
1226 "name": "get_current_weather",
1227 "arguments": "{\"format\":\"celsius\",\"location\":\"San Francisco, CA\"}"
1228 }
1229 }]
1230 })]);
1231
1232 normalize_tool_calls_arguments_in_messages(&mut messages);
1233
1234 let mut env = Environment::new();
1235 env.add_filter("tojson", super::super::tokcfg::tojson);
1236 env.add_template("t", tmpl).unwrap();
1237 let out = env
1238 .get_template("t")
1239 .unwrap()
1240 .render(context! { messages => messages.as_array().unwrap() })
1241 .unwrap();
1242
1243 assert_eq!(
1245 out,
1246 r#"{"format":"celsius","location":"San Francisco, CA"}"#
1247 );
1248 }
1249
1250 #[test]
1251 fn test_normalize_tool_arguments_items_loop() {
1252 let tmpl = r#"{% for k, v in messages[0].tool_calls[0].function.arguments|items %}{{k}}={{v}};{% endfor %}"#;
1253
1254 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1255 "role": "assistant",
1256 "tool_calls": [{
1257 "type": "function",
1258 "function": {
1259 "name": "f",
1260 "arguments": "{\"a\":1,\"b\":\"x\"}"
1261 }
1262 }]
1263 })]);
1264
1265 normalize_tool_calls_arguments_in_messages(&mut messages);
1266
1267 let mut env = Environment::new();
1268 env.add_template("t", tmpl).unwrap();
1269 let out = env
1270 .get_template("t")
1271 .unwrap()
1272 .render(context! { messages => messages.as_array().unwrap() })
1273 .unwrap();
1274
1275 assert!(out == "a=1;b=x;" || out == "b=x;a=1;");
1276 }
1277
1278 #[test]
1279 fn test_normalize_tool_arguments_legacy_function_call() {
1280 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1282 "role": "assistant",
1283 "function_call": {
1284 "name": "get_weather",
1285 "arguments": "{\"location\":\"NYC\"}"
1286 }
1287 })]);
1288
1289 normalize_function_call_arguments_in_messages(&mut messages);
1290
1291 assert_eq!(
1292 messages[0]["function_call"]["arguments"],
1293 serde_json::json!({"location": "NYC"})
1294 );
1295 }
1296
1297 #[test]
1298 fn test_normalize_tool_arguments_malformed_json_passthrough() {
1299 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1301 "role": "assistant",
1302 "tool_calls": [{
1303 "type": "function",
1304 "function": {
1305 "name": "f",
1306 "arguments": "not valid json at all"
1307 }
1308 }]
1309 })]);
1310
1311 normalize_tool_calls_arguments_in_messages(&mut messages);
1312
1313 assert_eq!(
1314 messages[0]["tool_calls"][0]["function"]["arguments"],
1315 serde_json::Value::String("not valid json at all".to_string())
1316 );
1317 }
1318
1319 #[test]
1320 fn test_normalize_tool_arguments_with_multimodal_content() {
1321 let json_str = r#"{
1322 "model": "gpt-4o",
1323 "messages": [
1324 {
1325 "role": "user",
1326 "content": [
1327 {"type": "text", "text": "Check this:"},
1328 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1329 {"type": "text", "text": "Interesting?"}
1330 ]
1331 },
1332 {
1333 "role": "assistant",
1334 "tool_calls": [{
1335 "id": "call_123",
1336 "type": "function",
1337 "function": {
1338 "name": "analyze_video",
1339 "arguments": "{\"url\":\"https://example.com/vid.mp4\",\"format\":\"mp4\"}"
1340 }
1341 }]
1342 }
1343 ]
1344 }"#;
1345
1346 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1347 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1348
1349 let mut messages =
1351 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1352
1353 normalize_tool_calls_arguments_in_messages(&mut messages);
1354
1355 assert!(messages[0]["content"].is_array());
1357 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1358
1359 assert!(messages[1]["tool_calls"][0]["function"]["arguments"].is_object());
1361 assert_eq!(
1362 messages[1]["tool_calls"][0]["function"]["arguments"]["url"],
1363 "https://example.com/vid.mp4"
1364 );
1365 }
1366
1367 #[test]
1369 fn test_may_be_fix_msg_content_string_to_array() {
1370 let json_str = r#"{
1371 "model": "gpt-4o",
1372 "messages": [
1373 {
1374 "role": "user",
1375 "content": "Hello, how are you?"
1376 }
1377 ]
1378 }"#;
1379
1380 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1381 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1382
1383 let messages =
1385 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1386
1387 assert!(messages[0]["content"].is_array());
1389 let content_array = messages[0]["content"].as_array().unwrap();
1390 assert_eq!(content_array.len(), 1);
1391 assert_eq!(content_array[0]["type"], "text");
1392 assert_eq!(content_array[0]["text"], "Hello, how are you?");
1393 }
1394
1395 #[test]
1397 fn test_may_be_fix_msg_content_array_preserved_with_multimodal() {
1398 let json_str = r#"{
1399 "model": "gpt-4o",
1400 "messages": [
1401 {
1402 "role": "user",
1403 "content": [
1404 {"type": "text", "text": "part 1"},
1405 {"type": "text", "text": "part 2"}
1406 ]
1407 }
1408 ]
1409 }"#;
1410
1411 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1412 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1413
1414 let messages =
1416 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1417
1418 assert!(messages[0]["content"].is_array());
1420 let content_array = messages[0]["content"].as_array().unwrap();
1421 assert_eq!(content_array.len(), 2);
1422 assert_eq!(content_array[0]["text"], "part 1");
1423 assert_eq!(content_array[1]["text"], "part 2");
1424 }
1425
1426 fn user() -> Msg {
1427 Msg::User(Default::default())
1428 }
1429 fn tool() -> Msg {
1430 Msg::Tool(Default::default())
1431 }
1432
1433 fn dummy_state(messages: Vec<Msg>) -> NvCreateChatCompletionRequest {
1434 let json = serde_json::json!({
1435 "model": "test-model",
1436 "messages": messages
1437 });
1438 serde_json::from_value(json).unwrap()
1439 }
1440
1441 #[test]
1442 fn add_after_user() {
1443 let s = dummy_state(vec![user()]);
1444 assert!(s.should_add_generation_prompt());
1445 }
1446
1447 #[test]
1448 fn add_after_tool() {
1449 let s = dummy_state(vec![tool()]);
1450 assert!(s.should_add_generation_prompt());
1451 }
1452
1453 #[test]
1454 fn add_when_empty() {
1455 let s = dummy_state(vec![]);
1456 assert!(s.should_add_generation_prompt());
1457 }
1458
1459 fn tool_aware_formatter(
1461 exclude_tools_when_tool_choice_none: bool,
1462 ) -> HfTokenizerConfigJsonFormatter {
1463 let template = r#"
1464{%- if tools is iterable and tools | length > 0 %}
1465TOOL_MODE tools={{ tools | length }}
1466{%- else %}
1467NORMAL_MODE
1468{%- endif %}
1469{{ messages[0].content }}"#;
1470
1471 let chat_template: super::tokcfg::ChatTemplate =
1472 serde_json::from_value(serde_json::json!({ "chat_template": template })).unwrap();
1473
1474 HfTokenizerConfigJsonFormatter::with_options(
1475 chat_template,
1476 ContextMixins::new(&[]),
1477 exclude_tools_when_tool_choice_none,
1478 )
1479 .unwrap()
1480 }
1481
1482 fn request_with_tool_choice(tool_choice: &str) -> NvCreateChatCompletionRequest {
1484 serde_json::from_value(serde_json::json!({
1485 "model": "test",
1486 "messages": [{"role": "user", "content": "hello"}],
1487 "tools": [{
1488 "type": "function",
1489 "function": {
1490 "name": "get_weather",
1491 "description": "Get weather",
1492 "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}
1493 }
1494 }],
1495 "tool_choice": tool_choice
1496 }))
1497 .unwrap()
1498 }
1499
1500 #[test]
1501 fn test_exclude_tools_strips_when_tool_choice_none() {
1502 let formatter = tool_aware_formatter(true);
1503 let request = request_with_tool_choice("none");
1504 let result = formatter.render(&request).unwrap();
1505 assert!(
1506 result.contains("NORMAL_MODE"),
1507 "With exclude_tools=true and tool_choice=none, tools should be stripped. Got: {}",
1508 result
1509 );
1510 }
1511
1512 #[test]
1513 fn test_exclude_tools_keeps_when_tool_choice_auto() {
1514 let formatter = tool_aware_formatter(true);
1515 let request = request_with_tool_choice("auto");
1516 let result = formatter.render(&request).unwrap();
1517 assert!(
1518 result.contains("TOOL_MODE"),
1519 "With tool_choice=auto, tools should be included. Got: {}",
1520 result
1521 );
1522 }
1523
1524 #[test]
1525 fn test_no_exclude_tools_keeps_when_tool_choice_none() {
1526 let formatter = tool_aware_formatter(false);
1527 let request = request_with_tool_choice("none");
1528 let result = formatter.render(&request).unwrap();
1529 assert!(
1530 result.contains("TOOL_MODE"),
1531 "With exclude_tools=false and tool_choice=none, tools should NOT be stripped. Got: {}",
1532 result
1533 );
1534 }
1535
1536 #[test]
1537 fn test_inject_reasoning_content_segments_with_tool_calls() {
1538 let mut messages = serde_json::json!([
1540 {
1541 "role": "user",
1542 "content": "What is sqrt(144) and sqrt(256)?"
1543 },
1544 {
1545 "role": "assistant",
1546 "content": "Let me calculate those.",
1547 "reasoning_content": ["I need to compute sqrt(144)", "Now sqrt(256)", ""],
1548 "tool_calls": [
1549 {
1550 "id": "call_0",
1551 "type": "function",
1552 "function": {
1553 "name": "calculator",
1554 "arguments": "{\"expr\": \"sqrt(144)\"}"
1555 }
1556 },
1557 {
1558 "id": "call_1",
1559 "type": "function",
1560 "function": {
1561 "name": "calculator",
1562 "arguments": "{\"expr\": \"sqrt(256)\"}"
1563 }
1564 }
1565 ]
1566 }
1567 ]);
1568
1569 inject_reasoning_content_into_messages(&mut messages);
1570
1571 let assistant = &messages[1];
1572
1573 assert!(
1575 assistant.get("reasoning_content").is_none(),
1576 "reasoning_content should be removed after injection"
1577 );
1578
1579 let content = assistant["content"].as_str().unwrap();
1581 assert!(
1582 content.starts_with("<think>I need to compute sqrt(144)</think>"),
1583 "content should start with first reasoning segment, got: {}",
1584 content
1585 );
1586 assert!(
1587 content.contains("<think>Now sqrt(256)</think>"),
1588 "content should contain second reasoning segment"
1589 );
1590 assert!(
1592 !content.contains("<think></think>"),
1593 "empty segments should be skipped"
1594 );
1595 assert!(
1597 content.ends_with("Let me calculate those."),
1598 "original content should be at the end, got: {}",
1599 content
1600 );
1601
1602 assert!(assistant.get("tool_calls").is_some());
1604 assert_eq!(assistant["tool_calls"].as_array().unwrap().len(), 2);
1605 }
1606
1607 #[test]
1608 fn test_inject_reasoning_content_text_variant() {
1609 let mut messages = serde_json::json!([
1610 {
1611 "role": "assistant",
1612 "content": "The answer is 42.",
1613 "reasoning_content": "Let me think about this carefully."
1614 }
1615 ]);
1616
1617 inject_reasoning_content_into_messages(&mut messages);
1618
1619 let assistant = &messages[0];
1620 assert!(assistant.get("reasoning_content").is_none());
1621 let content = assistant["content"].as_str().unwrap();
1622 assert_eq!(
1623 content,
1624 "<think>Let me think about this carefully.</think>The answer is 42."
1625 );
1626 }
1627
1628 #[test]
1629 fn test_inject_reasoning_content_null_content() {
1630 let mut messages = serde_json::json!([
1632 {
1633 "role": "assistant",
1634 "content": null,
1635 "reasoning_content": "Thinking...",
1636 "tool_calls": [{"id": "call_0", "type": "function", "function": {"name": "f", "arguments": "{}"}}]
1637 }
1638 ]);
1639
1640 inject_reasoning_content_into_messages(&mut messages);
1641
1642 let content = messages[0]["content"].as_str().unwrap();
1643 assert_eq!(content, "<think>Thinking...</think>");
1644 assert!(messages[0].get("reasoning_content").is_none());
1645 }
1646
1647 #[test]
1648 fn test_inject_reasoning_content_skips_non_assistant() {
1649 let mut messages = serde_json::json!([
1650 {
1651 "role": "user",
1652 "content": "hello",
1653 "reasoning_content": "should not be touched"
1654 }
1655 ]);
1656
1657 inject_reasoning_content_into_messages(&mut messages);
1658
1659 assert!(messages[0].get("reasoning_content").is_some());
1661 }
1662
1663 fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
1665 use super::tokcfg::ChatTemplate;
1666 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1667
1668 let template = r#"{%- for message in messages %}{{ message.role }}: {{ message.content }}
1671{%- endfor %}
1672{%- if add_generation_prompt %}assistant:{%- endif %}"#;
1673
1674 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1675 "chat_template": template
1676 }))
1677 .unwrap();
1678
1679 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
1680 }
1681
1682 #[test]
1685 fn test_reasoning_content_text_roundtrip_render() {
1686 use super::OAIPromptFormatter;
1687 let formatter = make_test_formatter();
1688
1689 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
1690 "model": "test-model",
1691 "messages": [
1692 {"role": "user", "content": "What is sqrt(144)?"},
1693 {
1694 "role": "assistant",
1695 "content": "The answer is 12.",
1696 "reasoning_content": "I need to compute the square root of 144."
1697 },
1698 {"role": "user", "content": "Are you sure?"}
1699 ]
1700 }))
1701 .unwrap();
1702
1703 let rendered = formatter.render(&request).unwrap();
1704
1705 assert!(
1706 rendered.contains("<think>I need to compute the square root of 144.</think>"),
1707 "reasoning_content must appear as <think> block, got: {}",
1708 rendered
1709 );
1710 assert!(
1711 rendered.contains("The answer is 12."),
1712 "original content must be preserved"
1713 );
1714 assert!(
1715 !rendered.contains("reasoning_content"),
1716 "raw reasoning_content field should not leak into prompt"
1717 );
1718 }
1719
1720 #[test]
1724 fn test_reasoning_content_agentic_tool_call_roundtrip_render() {
1725 use super::OAIPromptFormatter;
1726 let formatter = make_test_formatter();
1727
1728 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
1729 "model": "test-model",
1730 "messages": [
1731 {"role": "user", "content": "What is sqrt(144) + sqrt(256)?"},
1732 {
1733 "role": "assistant",
1734 "content": null,
1735 "reasoning_content": "I need to compute both square roots. Let me start with sqrt(144).",
1736 "tool_calls": [{
1737 "id": "call_0",
1738 "type": "function",
1739 "function": {
1740 "name": "calculator",
1741 "arguments": "{\"expr\": \"sqrt(144)\"}"
1742 }
1743 }]
1744 },
1745 {
1746 "role": "tool",
1747 "tool_call_id": "call_0",
1748 "content": "12"
1749 },
1750 {
1751 "role": "assistant",
1752 "content": "sqrt(144) = 12 and sqrt(256) = 16, so the answer is 28.",
1753 "reasoning_content": "Got 12 for sqrt(144). Now sqrt(256) = 16. Sum is 28."
1754 },
1755 {"role": "user", "content": "Thanks!"}
1756 ]
1757 }))
1758 .unwrap();
1759
1760 let rendered = formatter.render(&request).unwrap();
1761
1762 assert!(
1764 rendered.contains("<think>I need to compute both square roots"),
1765 "first turn reasoning must be in prompt, got: {}",
1766 rendered
1767 );
1768 assert!(
1770 rendered.contains("<think>Got 12 for sqrt(144)"),
1771 "second turn reasoning must be in prompt"
1772 );
1773 assert!(
1774 rendered.contains("the answer is 28"),
1775 "final answer content must be preserved"
1776 );
1777 assert!(
1779 !rendered.contains("reasoning_content"),
1780 "raw reasoning_content field should not leak into prompt"
1781 );
1782 }
1783
1784 #[test]
1786 fn test_reasoning_injected_when_template_ignores_it() {
1787 use super::OAIPromptFormatter;
1788 let formatter = make_test_formatter();
1789
1790 assert!(!formatter.template_handles_reasoning);
1792
1793 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
1794 "model": "test-model",
1795 "messages": [
1796 {"role": "user", "content": "Hello"},
1797 {
1798 "role": "assistant",
1799 "content": "Hi.",
1800 "reasoning_content": "The user said hello."
1801 },
1802 {"role": "user", "content": "Bye"}
1803 ]
1804 }))
1805 .unwrap();
1806
1807 let rendered = formatter.render(&request).unwrap();
1808 assert!(
1809 rendered.contains("<think>The user said hello.</think>"),
1810 "injection must happen when template ignores reasoning_content, got: {}",
1811 rendered
1812 );
1813 }
1814
1815 #[test]
1817 fn test_reasoning_not_injected_when_template_handles_it() {
1818 use super::tokcfg::ChatTemplate;
1819 use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
1820
1821 let template = r#"{%- for message in messages %}{%- if message.role == "assistant" and message.reasoning_content is defined and message.reasoning_content %}<think>{{ message.reasoning_content }}</think>
1823{%- endif %}{{ message.role }}: {{ message.content }}
1824{%- endfor %}
1825{%- if add_generation_prompt %}assistant:{%- endif %}"#;
1826
1827 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1828 "chat_template": template
1829 }))
1830 .unwrap();
1831
1832 let formatter =
1833 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1834
1835 assert!(formatter.template_handles_reasoning);
1837
1838 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
1839 "model": "test-model",
1840 "messages": [
1841 {"role": "user", "content": "Hello"},
1842 {
1843 "role": "assistant",
1844 "content": "Hi.",
1845 "reasoning_content": "The user said hello."
1846 },
1847 {"role": "user", "content": "Bye"}
1848 ]
1849 }))
1850 .unwrap();
1851
1852 let rendered = formatter.render(&request).unwrap();
1853
1854 assert!(
1856 rendered.contains("<think>The user said hello.</think>"),
1857 "template must render reasoning_content natively, got: {}",
1858 rendered
1859 );
1860 let think_count = rendered.matches("<think>").count();
1862 assert_eq!(
1863 think_count, 1,
1864 "must have exactly one <think> block (from template), got {} in: {}",
1865 think_count, rendered
1866 );
1867 }
1868
1869 const QWEN3_THINKING_TEMPLATE: &str = r##"{%- if tools %}
1873 {{- '<|im_start|>system\n' }}
1874 {%- if messages[0].role == 'system' %}
1875 {{- messages[0].content + '\n\n' }}
1876 {%- endif %}
1877 {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
1878 {%- for tool in tools %}
1879 {{- "\n" }}
1880 {{- tool | tojson }}
1881 {%- endfor %}
1882 {{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
1883{%- else %}
1884 {%- if messages[0].role == 'system' %}
1885 {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
1886 {%- endif %}
1887{%- endif %}
1888{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
1889{%- for message in messages[::-1] %}
1890 {%- set index = (messages|length - 1) - loop.index0 %}
1891 {%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
1892 {%- set ns.multi_step_tool = false %}
1893 {%- set ns.last_query_index = index %}
1894 {%- endif %}
1895{%- endfor %}
1896{%- for message in messages %}
1897 {%- if message.content is string %}
1898 {%- set content = message.content %}
1899 {%- else %}
1900 {%- set content = '' %}
1901 {%- endif %}
1902 {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
1903 {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
1904 {%- elif message.role == "assistant" %}
1905 {%- set reasoning_content = '' %}
1906 {%- if message.reasoning_content is string %}
1907 {%- set reasoning_content = message.reasoning_content %}
1908 {%- else %}
1909 {%- if '</think>' in content %}
1910 {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
1911 {%- set content = content.split('</think>')[-1].lstrip('\n') %}
1912 {%- endif %}
1913 {%- endif %}
1914 {%- if loop.index0 > ns.last_query_index %}
1915 {%- if loop.last or (not loop.last and reasoning_content) %}
1916 {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
1917 {%- else %}
1918 {{- '<|im_start|>' + message.role + '\n' + content }}
1919 {%- endif %}
1920 {%- else %}
1921 {{- '<|im_start|>' + message.role + '\n' + content }}
1922 {%- endif %}
1923 {%- if message.tool_calls %}
1924 {%- for tool_call in message.tool_calls %}
1925 {%- if (loop.first and content) or (not loop.first) %}
1926 {{- '\n' }}
1927 {%- endif %}
1928 {%- if tool_call.function %}
1929 {%- set tool_call = tool_call.function %}
1930 {%- endif %}
1931 {{- '<tool_call>\n{"name": "' }}
1932 {{- tool_call.name }}
1933 {{- '", "arguments": ' }}
1934 {%- if tool_call.arguments is string %}
1935 {{- tool_call.arguments }}
1936 {%- else %}
1937 {{- tool_call.arguments | tojson }}
1938 {%- endif %}
1939 {{- '}\n</tool_call>' }}
1940 {%- endfor %}
1941 {%- endif %}
1942 {{- '<|im_end|>\n' }}
1943 {%- elif message.role == "tool" %}
1944 {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
1945 {{- '<|im_start|>user' }}
1946 {%- endif %}
1947 {{- '\n<tool_response>\n' }}
1948 {{- content }}
1949 {{- '\n</tool_response>' }}
1950 {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
1951 {{- '<|im_end|>\n' }}
1952 {%- endif %}
1953 {%- endif %}
1954{%- endfor %}
1955{%- if add_generation_prompt %}
1956 {{- '<|im_start|>assistant\n<think>\n' }}
1957{%- endif %}"##;
1958
1959 fn qwen3_thinking_formatter() -> HfTokenizerConfigJsonFormatter {
1960 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1961 "chat_template": QWEN3_THINKING_TEMPLATE,
1962 }))
1963 .unwrap();
1964 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
1965 }
1966
1967 #[test]
1968 fn test_qwen3_thinking_template_flags_detected() {
1969 let formatter = qwen3_thinking_formatter();
1970 assert!(
1971 formatter.template_handles_reasoning,
1972 "template references reasoning_content directly"
1973 );
1974 assert!(
1977 formatter.default_template_handles_tool_calls_arguments_string,
1978 "default template branches on `arguments is string`"
1979 );
1980 assert!(
1981 formatter.tool_use_template_handles_tool_calls_arguments_string,
1982 "tool_use template branches on `arguments is string`"
1983 );
1984 }
1985
1986 #[test]
1996 fn test_qwen3_thinking_append_only_across_tool_use_turn() {
1997 let formatter = qwen3_thinking_formatter();
1998
1999 let tools = serde_json::json!([{
2000 "type": "function",
2001 "function": {
2002 "name": "get_weather",
2003 "description": "Get the current weather for a location",
2004 "parameters": {
2005 "type": "object",
2006 "properties": {
2007 "location": {"type": "string"},
2008 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
2009 },
2010 "required": ["location"]
2011 }
2012 }
2013 }]);
2014
2015 let turn1_request: NvCreateChatCompletionRequest =
2017 serde_json::from_value(serde_json::json!({
2018 "model": "qwen3-thinking",
2019 "messages": [
2020 {"role": "system", "content": "You are a helpful assistant."},
2021 {"role": "user", "content": "What's the weather in San Francisco?"},
2022 ],
2023 "tools": tools,
2024 }))
2025 .unwrap();
2026 let p1 = formatter.render(&turn1_request).unwrap();
2027
2028 let model_emitted = "I'll call get_weather for SF.\n\
2032 </think>\n\n\
2033 <tool_call>\n\
2034 {\"name\": \"get_weather\", \"arguments\": {\"location\": \"San Francisco\", \"unit\": \"celsius\"}}\n\
2035 </tool_call><|im_end|>\n";
2036 let wire_after_t1 = format!("{p1}{model_emitted}");
2037
2038 let turn2_request: NvCreateChatCompletionRequest =
2041 serde_json::from_value(serde_json::json!({
2042 "model": "qwen3-thinking",
2043 "messages": [
2044 {"role": "system", "content": "You are a helpful assistant."},
2045 {"role": "user", "content": "What's the weather in San Francisco?"},
2046 {
2047 "role": "assistant",
2048 "content": "",
2049 "reasoning_content": "I'll call get_weather for SF.",
2050 "tool_calls": [{
2051 "id": "call_sf",
2052 "type": "function",
2053 "function": {
2054 "name": "get_weather",
2055 "arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
2056 }
2057 }]
2058 },
2059 {
2060 "role": "tool",
2061 "tool_call_id": "call_sf",
2062 "content": "{\"temp\": 18, \"conditions\": \"Foggy\"}"
2063 }
2064 ],
2065 "tools": tools,
2066 }))
2067 .unwrap();
2068 let p2 = formatter.render(&turn2_request).unwrap();
2069
2070 if !p2.starts_with(&wire_after_t1) {
2071 let div = wire_after_t1
2073 .as_bytes()
2074 .iter()
2075 .zip(p2.as_bytes())
2076 .position(|(a, b)| a != b)
2077 .unwrap_or_else(|| wire_after_t1.len().min(p2.len()));
2078 let lo = div.saturating_sub(40);
2079 panic!(
2080 "turn-2 prompt is NOT a prefix-extension of [turn-1 + model bytes]\n \
2081 diverges at byte {div}\n \
2082 wire ends: ...{}|{}\n \
2083 t2 has: ...{}|{}",
2084 String::from_utf8_lossy(&wire_after_t1.as_bytes()[lo..div]),
2085 String::from_utf8_lossy(
2086 &wire_after_t1.as_bytes()[div..(div + 60).min(wire_after_t1.len())]
2087 ),
2088 String::from_utf8_lossy(&p2.as_bytes()[lo..div]),
2089 String::from_utf8_lossy(&p2.as_bytes()[div..(div + 60).min(p2.len())]),
2090 );
2091 }
2092
2093 let suffix = &p2[wire_after_t1.len()..];
2096 assert!(
2097 suffix.contains("<tool_response>"),
2098 "appended bytes must include the tool response, got: {suffix}"
2099 );
2100 assert!(
2101 suffix.ends_with("<|im_start|>assistant\n<think>\n"),
2102 "appended bytes must end with the next generation prompt, got: {suffix}"
2103 );
2104 }
2105}