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 if let Some(obj) = function.as_object_mut()
27 && !matches!(obj.get("description"), Some(serde_json::Value::String(_)))
28 {
29 obj.insert(
30 "description".to_string(),
31 serde_json::Value::String(String::new()),
32 );
33 }
34 }
35 if let Some(function) = tool.get_mut("function")
36 && let Some(parameters) = function.get_mut("parameters")
37 {
38 if parameters.is_object() {
40 let mut needs_type = false;
41 let mut needs_properties = false;
42 let is_empty = parameters
43 .as_object()
44 .map(|o| o.is_empty())
45 .unwrap_or(false);
46
47 if is_empty {
49 needs_type = true;
50 needs_properties = true;
51 } else {
52 if let Some(obj) = parameters.as_object() {
54 if !obj.contains_key("type") {
55 needs_type = true;
56 }
57 if !obj.contains_key("properties") {
58 needs_properties = true;
59 }
60 }
61 }
62
63 if (needs_type || needs_properties)
64 && let Some(obj) = parameters.as_object_mut()
65 {
66 if needs_type {
67 obj.insert(
68 "type".to_string(),
69 serde_json::Value::String("object".to_string()),
70 );
71 }
72 if needs_properties {
73 obj.insert(
74 "properties".to_string(),
75 serde_json::Value::Object(Default::default()),
76 );
77 }
78 }
79 }
80 }
81 updated_tools.push(tool);
82 }
83 }
84 Some(Value::from_serialize(&updated_tools))
85}
86
87const DEFAULT_MEDIA_TYPE_CONVERSIONS: &[(&str, &str)] = &[
90 ("image_url", "image"),
91 ("video_url", "video"),
92 ("audio_url", "audio"),
93];
94
95fn convert_media_url_to_placeholder(
97 content_array: &[serde_json::Value],
98 conversions: &[(&str, &str)],
99) -> Vec<serde_json::Value> {
100 content_array
101 .iter()
102 .map(|part| {
103 let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
104
105 if let Some((_, target_type)) = conversions.iter().find(|(src, _)| *src == part_type) {
106 serde_json::json!({"type": target_type})
107 } else {
108 part.clone()
109 }
110 })
111 .collect()
112}
113
114fn may_be_fix_msg_content(
115 messages: serde_json::Value,
116 preserve_arrays: bool,
117 image_placeholder_template: Option<&str>,
118) -> Value {
119 let Some(arr) = messages.as_array() else {
128 return Value::from_serialize(&messages);
129 };
130
131 let updated_messages: Vec<_> = arr
132 .iter()
133 .map(|msg| {
134 match msg.get("content") {
135 Some(serde_json::Value::String(text)) if preserve_arrays => {
137 let mut modified_msg = msg.clone();
138 if let Some(msg_object) = modified_msg.as_object_mut() {
139 let content_array = serde_json::json!([{
140 "type": "text",
141 "text": text
142 }]);
143 msg_object.insert("content".to_string(), content_array);
144 }
145 modified_msg
146 }
147 Some(serde_json::Value::Array(content_array)) => {
149 let content_array = convert_media_url_to_placeholder(
151 content_array,
152 DEFAULT_MEDIA_TYPE_CONVERSIONS,
153 );
154
155 let is_text_only_array = !content_array.is_empty()
157 && content_array.iter().all(|part| {
158 part.get("type")
159 .and_then(|type_field| type_field.as_str())
160 .map(|type_str| type_str == "text")
161 .unwrap_or(false)
162 });
163
164 let mut modified_msg = msg.clone();
165 if let Some(msg_object) = modified_msg.as_object_mut() {
166 if is_text_only_array && !preserve_arrays {
167 let text_parts: Vec<&str> = content_array
169 .iter()
170 .filter_map(|part| part.get("text")?.as_str())
171 .collect();
172 let concatenated_text = text_parts.join("\n");
173 msg_object.insert(
174 "content".to_string(),
175 serde_json::Value::String(concatenated_text),
176 );
177 } else if !preserve_arrays
178 && !content_array.is_empty()
179 && let Some(placeholder_tpl) = image_placeholder_template
180 {
181 let flattened = flatten_mixed_content(&content_array, placeholder_tpl);
194 msg_object.insert(
195 "content".to_string(),
196 serde_json::Value::String(flattened),
197 );
198 } else {
199 msg_object.insert(
201 "content".to_string(),
202 serde_json::Value::Array(content_array),
203 );
204 }
205 }
206 modified_msg
207 }
208 _ => msg.clone(), }
210 })
211 .collect();
212
213 Value::from_serialize(&updated_messages)
214}
215
216fn flatten_mixed_content(parts: &[serde_json::Value], placeholder_tpl: &str) -> String {
239 let mut out = String::new();
240 let mut img_idx: u32 = 1;
241 for part in parts {
242 let type_str = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
243 if type_str == "text" {
244 if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
245 out.push_str(text);
246 }
247 } else if !type_str.is_empty() {
248 let placeholder = placeholder_tpl.replace("{n}", &img_idx.to_string());
249 out.push_str(&placeholder);
250 img_idx += 1;
251 }
252 }
253 out
254}
255
256fn normalize_tool_calls_arguments_in_messages(messages: &mut serde_json::Value) {
257 let Some(msgs) = messages.as_array_mut() else {
262 return;
263 };
264
265 for msg in msgs.iter_mut() {
266 if let Some(tool_calls) = msg.get_mut("tool_calls").and_then(|v| v.as_array_mut()) {
267 for tc in tool_calls {
268 if let Some(function) = tc.get_mut("function").and_then(|v| v.as_object_mut())
269 && let Some(args) = function.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 }
278}
279
280fn normalize_function_call_arguments_in_messages(messages: &mut serde_json::Value) {
281 let Some(msgs) = messages.as_array_mut() else {
286 return;
287 };
288
289 for msg in msgs.iter_mut() {
290 if let Some(function_call) = msg.get_mut("function_call").and_then(|v| v.as_object_mut())
291 && let Some(args) = function_call.get_mut("arguments")
292 && let Some(s) = args.as_str()
293 && let Ok(parsed) = serde_json::from_str(s)
294 {
295 *args = parsed;
296 }
297 }
298}
299
300fn inject_reasoning_content_into_messages(messages: &mut serde_json::Value) {
314 let Some(msgs) = messages.as_array_mut() else {
315 return;
316 };
317
318 for msg in msgs.iter_mut() {
319 if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") {
320 continue;
321 }
322
323 let reasoning = match msg.get("reasoning_content") {
324 Some(serde_json::Value::String(s)) if !s.is_empty() => {
325 format!("<think>{}</think>", s)
326 }
327 Some(serde_json::Value::Array(segments)) => {
328 let mut result = String::new();
329 for seg in segments {
330 if let Some(s) = seg.as_str()
331 && !s.is_empty()
332 {
333 result.push_str("<think>");
334 result.push_str(s);
335 result.push_str("</think>");
336 }
337 }
338 if result.is_empty() {
339 continue;
340 }
341 result
342 }
343 _ => continue,
344 };
345
346 match msg.get("content") {
347 Some(serde_json::Value::String(s)) if !s.is_empty() => {
349 msg["content"] = serde_json::Value::String(format!("{}{}", reasoning, s));
350 }
351 None | Some(serde_json::Value::Null) | Some(serde_json::Value::String(_)) => {
352 msg["content"] = serde_json::Value::String(reasoning);
353 }
354 Some(serde_json::Value::Array(_)) => {
356 let think_part = serde_json::json!({
357 "type": "text",
358 "text": reasoning
359 });
360 if let Some(arr) = msg.get_mut("content").and_then(|v| v.as_array_mut()) {
361 arr.insert(0, think_part);
362 }
363 }
364 _ => continue,
366 }
367
368 if let Some(obj) = msg.as_object_mut() {
371 obj.remove("reasoning_content");
372 }
373 }
374}
375
376impl OAIChatLikeRequest for dynamo_protocols::types::CreateChatCompletionRequest {
382 fn model(&self) -> String {
383 self.model.clone()
384 }
385
386 fn messages(&self) -> Value {
387 let messages_json = serde_json::to_value(&self.messages).unwrap();
388 Value::from_serialize(&messages_json)
389 }
390
391 fn typed_messages(&self) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
392 Some(self.messages.as_slice())
393 }
394
395 fn tools(&self) -> Option<Value> {
396 if self.tools.is_none() {
397 None
398 } else {
399 Some(may_be_fix_tool_schema(
400 serde_json::to_value(&self.tools).unwrap(),
401 )?)
402 }
403 }
404
405 fn tool_choice(&self) -> Option<Value> {
406 if self.tool_choice.is_none() {
407 None
408 } else {
409 Some(Value::from_serialize(&self.tool_choice))
410 }
411 }
412
413 fn response_format(&self) -> Option<Value> {
414 self.response_format.as_ref().map(Value::from_serialize)
415 }
416
417 fn reasoning_effort(&self) -> Option<Value> {
418 self.reasoning_effort.as_ref().map(Value::from_serialize)
419 }
420
421 fn should_add_generation_prompt(&self) -> bool {
422 true
424 }
425
426 fn extract_text(&self) -> Option<TextInput> {
427 Some(TextInput::Single(String::new()))
428 }
429
430 fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
431 self.mm_processor_kwargs.as_ref()
432 }
433}
434
435impl OAIPromptFormatter for HfTokenizerConfigJsonFormatter {
436 fn supports_add_generation_prompt(&self) -> bool {
437 self.supports_add_generation_prompt
438 }
439
440 fn image_placeholder_template(&self) -> Option<&'static str> {
441 self.image_placeholder_template
442 }
443
444 fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
445 let mixins = Value::from_dyn_object(self.mixins.clone());
446
447 let tools = req.tools();
448 let tools = if self.exclude_tools_when_tool_choice_none {
451 match req.tool_choice() {
452 Some(ref tc) if tc.as_str() == Some("none") => None,
453 _ => tools,
454 }
455 } else {
456 tools
457 };
458 let has_tools = tools.as_ref().and_then(|v| v.len()).is_some_and(|l| l > 0);
460 let add_generation_prompt = req.should_add_generation_prompt();
461
462 tracing::trace!(
463 "Rendering prompt with tools: {:?}, add_generation_prompt: {}",
464 has_tools,
465 add_generation_prompt
466 );
467
468 let messages_canonical = req.messages();
469 let mut messages_for_template: serde_json::Value =
470 serde_json::to_value(&messages_canonical).unwrap();
471
472 messages_for_template = serde_json::to_value(may_be_fix_msg_content(
473 messages_for_template,
474 self.requires_content_arrays,
475 self.image_placeholder_template,
476 ))
477 .unwrap();
478
479 let (template_name, template_handles_tool_calls_args_string, template_handles_reasoning) =
486 if has_tools {
487 (
488 "tool_use",
489 self.tool_use_template_handles_tool_calls_arguments_string,
490 self.tool_use_template_handles_reasoning,
491 )
492 } else {
493 (
494 "default",
495 self.default_template_handles_tool_calls_arguments_string,
496 self.default_template_handles_reasoning,
497 )
498 };
499
500 if !template_handles_tool_calls_args_string {
507 normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
508 }
509 normalize_function_call_arguments_in_messages(&mut messages_for_template);
513
514 if !template_handles_reasoning {
519 inject_reasoning_content_into_messages(&mut messages_for_template);
520 }
521
522 let ctx = context! {
523 messages => messages_for_template,
524 tools => tools,
525 bos_token => self.config.bos_tok(),
526 eos_token => self.config.eos_tok(),
527 unk_token => self.config.unk_tok(),
528 add_generation_prompt => add_generation_prompt,
529 ..mixins
530 };
531
532 let ctx = if let Some(args) = req.chat_template_args() {
534 let extra = Value::from_serialize(args);
535 context! { ..ctx, ..extra }
536 } else {
537 ctx
538 };
539
540 let tmpl: minijinja::Template<'_, '_> = self.env.get_template(template_name)?;
541 Ok(tmpl.render(&ctx)?)
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use dynamo_protocols::types::ChatCompletionRequestMessage as Msg;
549 use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
552 use minijinja::{Environment, context};
553
554 #[test]
562 fn test_render_long_conversation_does_not_overflow_stack() {
563 let handle = std::thread::Builder::new()
564 .stack_size(2 * 1024 * 1024)
565 .spawn(|| {
566 let template_string = concat!(
567 "{%- set ns = namespace(items=[]) -%}",
568 "{%- for m in messages -%}",
569 "{%- set ns.items = ns.items + [m] -%}",
570 "{%- endfor -%}",
571 "COUNT={{ ns.items | length }}"
572 );
573 let chat_template: ChatTemplate =
574 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
575 .unwrap();
576 let formatter =
577 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[]))
578 .unwrap();
579
580 let n = 3000;
581 let messages: Vec<serde_json::Value> = (0..n)
582 .map(|i| serde_json::json!({"role": "user", "content": format!("turn {i}")}))
583 .collect();
584 let request: NvCreateChatCompletionRequest =
585 serde_json::from_value(serde_json::json!({
586 "model": "test",
587 "messages": messages,
588 }))
589 .unwrap();
590
591 let rendered = formatter.render(&request).unwrap();
593 assert_eq!(rendered.trim(), format!("COUNT={n}"));
594 })
595 .unwrap();
596 handle.join().unwrap();
597 }
598
599 #[test]
610 #[ignore]
611 fn dump_gptoss_tool_prompt() {
612 use super::tokcfg::ChatTemplate;
613 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
614
615 let path = std::env::var("GPTOSS_CHAT_TEMPLATE").expect(
616 "set GPTOSS_CHAT_TEMPLATE to the tokenizer_config.json, chat_template.jinja, or model dir path",
617 );
618 let input_path = std::path::Path::new(&path);
619 let file_path = if input_path.is_dir() {
620 input_path.join("tokenizer_config.json")
622 } else {
623 input_path.to_path_buf()
624 };
625 let raw = std::fs::read_to_string(&file_path).expect("read chat template file");
626 let template_string: String = match serde_json::from_str::<serde_json::Value>(&raw) {
633 Ok(v) if v.get("chat_template").is_some() => v["chat_template"]
634 .as_str()
635 .expect("chat_template field must be a string")
636 .to_string(),
637 _ => {
638 let sibling = std::path::Path::new(&path)
639 .parent()
640 .map(|d| d.join("chat_template.jinja"));
641 match sibling {
642 Some(p) if p.exists() => {
643 eprintln!(
644 "[info] {path} had no chat_template field; using {}",
645 p.display()
646 );
647 std::fs::read_to_string(&p).expect("read sibling chat_template.jinja")
648 }
649 _ => raw,
650 }
651 }
652 };
653
654 assert!(
657 template_string.contains("{%") || template_string.contains("{{"),
658 "resolved template has no Jinja tags — GPTOSS_CHAT_TEMPLATE ({path}) is probably \
659 tokenizer_config.json with no chat_template field and no sibling chat_template.jinja. \
660 Point it at the chat_template.jinja file."
661 );
662
663 let chat_template: ChatTemplate =
664 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
665 .unwrap();
666
667 let formatter =
668 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
669
670 let request: NvCreateChatCompletionRequest = serde_json::from_str(
672 r#"{
673 "model": "openai/gpt-oss-120b",
674 "messages": [{"role":"user","content":"Search the repo for the string \"countHook\"."}],
675 "tools": [
676 {"type":"function","function":{"name":"grep","description":"search files","parameters":{"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}}},
677 {"type":"function","function":{"name":"read","description":"read a file","parameters":{"type":"object","properties":{"filePath":{"type":"string"}},"required":["filePath"]}}}
678 ]
679 }"#,
680 )
681 .unwrap();
682
683 let rendered = formatter.render(&request).unwrap();
684 eprintln!("================ RENDERED gpt-oss PROMPT (tools declared) ================");
685 eprintln!("{rendered}");
686 eprintln!("================ END RENDERED PROMPT ================");
687 eprintln!("[diagnostics] does the rendered prompt contain…");
688 for needle in [
689 "commentary",
690 "Calls to these tools",
691 "functions",
692 "# Tools",
693 "<|channel|>",
694 "constrain",
695 "analysis",
696 ] {
697 eprintln!(
698 " {:>22}: {}",
699 format!("{needle:?}"),
700 rendered.contains(needle)
701 );
702 }
703 }
704
705 #[test]
707 fn test_convert_media_url_to_placeholder_single_type() {
708 let content_array = vec![
709 serde_json::json!({"type": "text", "text": "Check this image:"}),
710 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
711 serde_json::json!({"type": "text", "text": "What do you see?"}),
712 ];
713
714 let conversions = &[("image_url", "image")];
715 let result = convert_media_url_to_placeholder(&content_array, conversions);
716
717 assert_eq!(result.len(), 3);
718 assert_eq!(result[0]["type"], "text");
720 assert_eq!(result[0]["text"], "Check this image:");
721 assert_eq!(result[1]["type"], "image");
723 assert!(result[1].get("image_url").is_none());
724 assert_eq!(result[2]["type"], "text");
726 assert_eq!(result[2]["text"], "What do you see?");
727 }
728
729 #[test]
731 fn test_convert_media_url_to_placeholder_multiple_same_type() {
732 let content_array = vec![
733 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}}),
734 serde_json::json!({"type": "text", "text": "vs"}),
735 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}),
736 ];
737
738 let conversions = &[("image_url", "image")];
739 let result = convert_media_url_to_placeholder(&content_array, conversions);
740
741 assert_eq!(result.len(), 3);
742 assert_eq!(result[0]["type"], "image");
743 assert_eq!(result[1]["type"], "text");
744 assert_eq!(result[2]["type"], "image");
745 }
746
747 #[test]
749 fn test_convert_media_url_to_placeholder_selective_conversion() {
750 let content_array = vec![
751 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
752 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
753 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
754 ];
755
756 let conversions = &[("image_url", "image")];
758 let result = convert_media_url_to_placeholder(&content_array, conversions);
759
760 assert_eq!(result.len(), 3);
761 assert_eq!(result[0]["type"], "audio_url");
763 assert!(result[0].get("audio_url").is_some());
764 assert_eq!(result[1]["type"], "video_url");
765 assert!(result[1].get("video_url").is_some());
766 assert_eq!(result[2]["type"], "image");
768 assert!(result[2].get("image_url").is_none());
769 }
770
771 #[test]
773 fn test_convert_media_url_to_placeholder_multiple_types() {
774 let content_array = vec![
775 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
776 serde_json::json!({"type": "text", "text": "and listen to"}),
777 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
778 serde_json::json!({"type": "text", "text": "and watch"}),
779 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
780 ];
781
782 let conversions = &[
784 ("image_url", "image"),
785 ("audio_url", "audio"),
786 ("video_url", "video"),
787 ];
788 let result = convert_media_url_to_placeholder(&content_array, conversions);
789
790 assert_eq!(result.len(), 5);
791 assert_eq!(result[0]["type"], "image");
792 assert!(result[0].get("image_url").is_none());
793 assert_eq!(result[1]["type"], "text");
794 assert_eq!(result[2]["type"], "audio");
795 assert!(result[2].get("audio_url").is_none());
796 assert_eq!(result[3]["type"], "text");
797 assert_eq!(result[4]["type"], "video");
798 assert!(result[4].get("video_url").is_none());
799 }
800
801 #[test]
803 fn test_convert_media_url_to_placeholder_no_conversions() {
804 let content_array = vec![
805 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
806 serde_json::json!({"type": "text", "text": "hello"}),
807 ];
808
809 let conversions: &[(&str, &str)] = &[];
810 let result = convert_media_url_to_placeholder(&content_array, conversions);
811
812 assert_eq!(result.len(), 2);
813 assert_eq!(result[0]["type"], "image_url");
815 assert!(result[0].get("image_url").is_some());
816 assert_eq!(result[1]["type"], "text");
817 }
818
819 #[test]
822 fn test_default_media_type_conversions_only_converts_image_url() {
823 let content_array = vec![
824 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
825 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
826 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
827 serde_json::json!({"type": "text", "text": "hello"}),
828 ];
829
830 let result =
832 convert_media_url_to_placeholder(&content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
833
834 assert_eq!(result.len(), 4);
835
836 assert_eq!(result[0]["type"], "image");
838 assert!(result[0].get("image_url").is_none());
839
840 assert_eq!(result[1]["type"], "video");
842 assert!(result[1].get("video_url").is_none());
843
844 assert_eq!(result[2]["type"], "audio");
846 assert!(result[2].get("audio_url").is_none());
847
848 assert_eq!(result[3]["type"], "text");
850 assert_eq!(result[3]["text"], "hello");
851 }
852
853 #[test]
854 fn test_may_be_fix_tool_schema_missing_type_and_properties() {
855 let json_str = r#"{
856 "model": "gpt-4o",
857 "messages": [],
858 "tools": [
859 {
860 "type": "function",
861 "function": {
862 "name": "get_weather",
863 "description": "Get the current weather in a given location",
864 "parameters": {},
865 "strict": null
866 }
867 }
868 ]
869 }"#;
870
871 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
872 let tools = serde_json::to_value(request.tools()).unwrap();
873
874 assert!(tools[0]["function"]["parameters"]["type"] == "object");
875 assert!(
876 tools[0]["function"]["parameters"]["properties"]
877 == serde_json::Value::Object(Default::default())
878 );
879 }
880
881 #[test]
882 fn test_may_be_fix_tool_schema_missing_type() {
883 let json_str = r#"{
884 "model": "gpt-4o",
885 "messages": [],
886 "tools": [
887 {
888 "type": "function",
889 "function": {
890 "name": "get_weather",
891 "description": "Get the current weather in a given location",
892 "parameters": {
893 "properties": {
894 "location": {
895 "type": "string",
896 "description": "City and state, e.g., 'San Francisco, CA'"
897 }
898 }
899 },
900 "strict": null
901 }
902 }
903 ]
904 }"#;
905 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
906
907 let tools = serde_json::to_value(request.tools()).unwrap();
908
909 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
910
911 let mut expected_properties = serde_json::Map::new();
912 let mut location = serde_json::Map::new();
913 location.insert(
914 "type".to_string(),
915 serde_json::Value::String("string".to_string()),
916 );
917 location.insert(
918 "description".to_string(),
919 serde_json::Value::String("City and state, e.g., 'San Francisco, CA'".to_string()),
920 );
921 expected_properties.insert("location".to_string(), serde_json::Value::Object(location));
922
923 assert_eq!(
924 tools[0]["function"]["parameters"]["properties"],
925 serde_json::Value::Object(expected_properties)
926 );
927 }
928
929 #[test]
930 fn test_may_be_fix_tool_schema_missing_properties() {
931 let json_str = r#"{
932 "model": "gpt-4o",
933 "messages": [],
934 "tools": [
935 {
936 "type": "function",
937 "function": {
938 "name": "get_weather",
939 "description": "Get the current weather in a given location",
940 "parameters": {"type": "object"},
941 "strict": null
942 }
943 }
944 ]
945 }"#;
946
947 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
948 let tools = serde_json::to_value(request.tools()).unwrap();
949
950 assert_eq!(
951 tools[0]["function"]["parameters"]["properties"],
952 serde_json::Value::Object(Default::default())
953 );
954 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
955 }
956
957 #[test]
958 fn test_may_be_fix_tool_schema_missing_description() {
959 let json_str = r#"{
963 "model": "gpt-4o",
964 "messages": [],
965 "tools": [
966 {
967 "type": "function",
968 "function": {
969 "name": "noop",
970 "parameters": {
971 "type": "object",
972 "properties": { "x": { "type": "string" } },
973 "required": ["x"],
974 "additionalProperties": false
975 },
976 "strict": null
977 }
978 }
979 ]
980 }"#;
981
982 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
983 let tools = serde_json::to_value(request.tools()).unwrap();
984
985 assert_eq!(
986 tools[0]["function"]["description"],
987 serde_json::Value::String(String::new())
988 );
989 }
990
991 #[test]
992 fn test_may_be_fix_tool_schema_null_description() {
993 let json_str = r#"{
995 "model": "gpt-4o",
996 "messages": [],
997 "tools": [
998 {
999 "type": "function",
1000 "function": {
1001 "name": "noop",
1002 "description": null,
1003 "parameters": {"type": "object", "properties": {}},
1004 "strict": null
1005 }
1006 }
1007 ]
1008 }"#;
1009
1010 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1011 let tools = serde_json::to_value(request.tools()).unwrap();
1012
1013 assert_eq!(
1014 tools[0]["function"]["description"],
1015 serde_json::Value::String(String::new())
1016 );
1017 }
1018
1019 #[test]
1020 fn test_may_be_fix_tool_schema_preserves_description() {
1021 let json_str = r#"{
1023 "model": "gpt-4o",
1024 "messages": [],
1025 "tools": [
1026 {
1027 "type": "function",
1028 "function": {
1029 "name": "get_weather",
1030 "description": "Get the current weather in a given location",
1031 "parameters": {"type": "object", "properties": {}},
1032 "strict": null
1033 }
1034 }
1035 ]
1036 }"#;
1037
1038 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1039 let tools = serde_json::to_value(request.tools()).unwrap();
1040
1041 assert_eq!(
1042 tools[0]["function"]["description"],
1043 "Get the current weather in a given location"
1044 );
1045 }
1046
1047 #[test]
1049 fn test_may_be_fix_msg_content_user_multipart() {
1050 let json_str = r#"{
1051 "model": "gpt-4o",
1052 "messages": [
1053 {
1054 "role": "user",
1055 "content": [
1056 {"type": "text", "text": "part 1"},
1057 {"type": "text", "text": "part 2"}
1058 ]
1059 }
1060 ]
1061 }"#;
1062
1063 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1064 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1065
1066 let messages =
1068 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1069
1070 assert_eq!(
1072 messages[0]["content"],
1073 serde_json::Value::String("part 1\npart 2".to_string())
1074 );
1075 }
1076
1077 #[test]
1080 fn test_may_be_fix_msg_content_mixed_messages() {
1081 let json_str = r#"{
1082 "model": "gpt-4o",
1083 "messages": [
1084 {
1085 "role": "system",
1086 "content": "You are a helpful assistant"
1087 },
1088 {
1089 "role": "user",
1090 "content": [
1091 {"type": "text", "text": "Hello"},
1092 {"type": "text", "text": "World"}
1093 ]
1094 },
1095 {
1096 "role": "assistant",
1097 "content": "Hi there!"
1098 },
1099 {
1100 "role": "user",
1101 "content": [
1102 {"type": "text", "text": "Another"},
1103 {"type": "text", "text": "multi-part"},
1104 {"type": "text", "text": "message"}
1105 ]
1106 }
1107 ]
1108 }"#;
1109
1110 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1111 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1112
1113 let messages =
1115 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1116
1117 assert_eq!(
1119 messages[0]["content"],
1120 serde_json::Value::String("You are a helpful assistant".to_string())
1121 );
1122
1123 assert_eq!(
1125 messages[1]["content"],
1126 serde_json::Value::String("Hello\nWorld".to_string())
1127 );
1128
1129 assert_eq!(
1131 messages[2]["content"],
1132 serde_json::Value::String("Hi there!".to_string())
1133 );
1134
1135 assert_eq!(
1137 messages[3]["content"],
1138 serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
1139 );
1140 }
1141
1142 #[test]
1144 fn test_may_be_fix_msg_content_empty_array() {
1145 let json_str = r#"{
1146 "model": "gpt-4o",
1147 "messages": [
1148 {
1149 "role": "user",
1150 "content": []
1151 }
1152 ]
1153 }"#;
1154
1155 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1156 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1157
1158 let messages =
1160 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1161
1162 assert!(messages[0]["content"].is_array());
1164 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1165 }
1166
1167 #[test]
1174 fn test_may_be_fix_msg_content_empty_array_with_placeholder_template() {
1175 let json_str = r#"{
1176 "model": "phi-3-vision",
1177 "messages": [
1178 {
1179 "role": "user",
1180 "content": []
1181 }
1182 ]
1183 }"#;
1184
1185 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1186 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1187
1188 let messages = serde_json::to_value(may_be_fix_msg_content(
1191 messages_raw,
1192 false,
1193 Some("<|image_{n}|>"),
1194 ))
1195 .unwrap();
1196
1197 assert!(
1198 messages[0]["content"].is_array(),
1199 "empty array should be preserved as `[]`, not flattened to `\"\"`"
1200 );
1201 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1202 }
1203
1204 #[test]
1206 fn test_may_be_fix_msg_content_single_text() {
1207 let json_str = r#"{
1208 "model": "gpt-4o",
1209 "messages": [
1210 {
1211 "role": "user",
1212 "content": "Simple text message"
1213 }
1214 ]
1215 }"#;
1216
1217 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1218 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1219
1220 let messages =
1222 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1223
1224 assert_eq!(
1226 messages[0]["content"],
1227 serde_json::Value::String("Simple text message".to_string())
1228 );
1229 }
1230
1231 #[test]
1234 fn test_may_be_fix_msg_content_mixed_types() {
1235 let json_str = r#"{
1236 "model": "gpt-4o",
1237 "messages": [
1238 {
1239 "role": "user",
1240 "content": [
1241 {"type": "text", "text": "Check this image:"},
1242 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
1243 {"type": "text", "text": "What do you see?"}
1244 ]
1245 }
1246 ]
1247 }"#;
1248
1249 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1250 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1251
1252 let messages =
1254 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1255
1256 assert!(messages[0]["content"].is_array());
1259 let content_array = messages[0]["content"].as_array().unwrap();
1260 assert_eq!(content_array.len(), 3);
1261 assert_eq!(content_array[0]["type"], "text");
1262 assert_eq!(content_array[1]["type"], "image");
1263 assert!(content_array[1].get("image_url").is_none());
1264 assert_eq!(content_array[2]["type"], "text");
1265 }
1266
1267 #[test]
1273 fn test_may_be_fix_msg_content_flattens_phi3_style() {
1274 let json_str = r#"{
1275 "model": "phi-3-vision",
1276 "messages": [
1277 {
1278 "role": "user",
1279 "content": [
1280 {"type": "text", "text": "First "},
1281 {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}},
1282 {"type": "text", "text": " then "},
1283 {"type": "image_url", "image_url": {"url": "https://example.com/b.jpg"}},
1284 {"type": "text", "text": "?"}
1285 ]
1286 }
1287 ]
1288 }"#;
1289 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1290 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1291
1292 let messages = serde_json::to_value(may_be_fix_msg_content(
1293 messages_raw,
1294 false,
1295 Some("<|image_{n}|>"),
1296 ))
1297 .unwrap();
1298
1299 let content = messages[0]["content"].as_str().expect("content flattened");
1300 assert_eq!(content, "First <|image_1|> then <|image_2|>?");
1301 }
1302
1303 #[test]
1305 fn test_may_be_fix_msg_content_flattens_llava_style() {
1306 let json_str = r#"{
1307 "model": "llava-1.5-7b-hf",
1308 "messages": [
1309 {
1310 "role": "user",
1311 "content": [
1312 {"type": "text", "text": "Describe: "},
1313 {"type": "image_url", "image_url": {"url": "https://example.com/x.jpg"}}
1314 ]
1315 }
1316 ]
1317 }"#;
1318 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1319 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1320
1321 let messages =
1322 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some("<image>")))
1323 .unwrap();
1324
1325 let content = messages[0]["content"].as_str().expect("content flattened");
1326 assert_eq!(content, "Describe: <image>");
1327 }
1328
1329 #[test]
1335 fn test_may_be_fix_msg_content_flattens_empty_placeholder() {
1336 let json_str = r#"{
1337 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1338 "messages": [
1339 {
1340 "role": "user",
1341 "content": [
1342 {"type": "text", "text": "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"},
1343 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1344 ]
1345 }
1346 ]
1347 }"#;
1348 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1349 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1350
1351 let messages =
1352 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some(""))).unwrap();
1353
1354 let content = messages[0]["content"].as_str().expect("content flattened");
1355 assert_eq!(
1356 content,
1357 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
1358 );
1359 }
1360
1361 #[test]
1368 fn test_render_nemotron_parse_passthrough() {
1369 use super::super::tokcfg::ChatTemplate;
1370 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1371
1372 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1373 "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}"
1374 }))
1375 .unwrap();
1376 let formatter =
1377 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1378
1379 for prompt in [
1380 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>",
1381 "</s><s><predict_bbox><predict_classes><output_markdown><predict_text_in_pic>",
1382 ] {
1383 let request: NvCreateChatCompletionRequest =
1384 serde_json::from_value(serde_json::json!({
1385 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1386 "messages": [{
1387 "role": "user",
1388 "content": [
1389 {"type": "text", "text": prompt},
1390 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1391 ]
1392 }]
1393 }))
1394 .unwrap();
1395
1396 let rendered = formatter.render(&request).unwrap();
1397 assert_eq!(
1398 rendered, prompt,
1399 "rendered prompt must be the control tokens only, with no JSON-serialized image array"
1400 );
1401 }
1402 }
1403
1404 #[test]
1407 fn test_may_be_fix_msg_content_non_text_only() {
1408 let json_str = r#"{
1409 "model": "gpt-4o",
1410 "messages": [
1411 {
1412 "role": "user",
1413 "content": [
1414 {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}},
1415 {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}
1416 ]
1417 }
1418 ]
1419 }"#;
1420
1421 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1422 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1423
1424 let messages =
1426 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1427
1428 assert!(messages[0]["content"].is_array());
1430 let content_array = messages[0]["content"].as_array().unwrap();
1431 assert_eq!(content_array.len(), 2);
1432 assert_eq!(content_array[0]["type"], "image");
1433 assert_eq!(content_array[1]["type"], "image");
1434 }
1435
1436 #[test]
1437 fn test_none_tools_safe_for_all_templates() {
1438 use super::tokcfg::ChatTemplate;
1439 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1440
1441 let length_template = r#"
1445{%- if tools is iterable and tools | length > 0 %}
1446Tools available: {{ tools | length }}
1447{%- else %}
1448No tools
1449{%- endif %}
1450"#;
1451
1452 let no_tool_template = r#"
1455{%- if tools is not none %}
1456TOOL MODE
1457{%- else %}
1458NORMAL MODE
1459{%- endif %}
1460"#;
1461
1462 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1463 "chat_template": [
1464 {"safe_length": length_template},
1465 {"no_tool": no_tool_template}
1466 ]
1467 }))
1468 .unwrap();
1469
1470 let formatter =
1471 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1472
1473 let ctx = context! { tools => Option::<Value>::None };
1474
1475 let result1 = formatter
1476 .env
1477 .get_template("safe_length")
1478 .unwrap()
1479 .render(&ctx);
1480 println!("Safe length template with no tools => None: {:?}", result1);
1481 assert!(
1482 result1.is_ok(),
1483 "Jinja template with and conditional and length filter should handle None: {:?}",
1484 result1
1485 );
1486 assert!(
1487 result1.unwrap().contains("No tools"),
1488 "Should show 'No tools'"
1489 );
1490
1491 let result2 = formatter.env.get_template("no_tool").unwrap().render(&ctx);
1492 println!("Default template with no tools => None: {:?}", result2);
1493 assert!(
1494 result2.is_ok(),
1495 "Jinja template with if tools is not none conditional should handle None: {:?}",
1496 result2
1497 );
1498 assert!(result2.unwrap().contains("NORMAL MODE"));
1499 }
1500
1501 #[test]
1503 fn test_may_be_fix_msg_content_multiple_content_types() {
1504 let json_str = r#"{
1506 "model": "gpt-4o",
1507 "messages": [
1508 {
1509 "role": "user",
1510 "content": [
1511 {"type": "text", "text": "Listen to this:"},
1512 {"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}},
1513 {"type": "text", "text": "And look at:"},
1514 {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
1515 {"type": "text", "text": "What do you think?"}
1516 ]
1517 }
1518 ]
1519 }"#;
1520
1521 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1522 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1523 let messages =
1524 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1525
1526 assert!(messages[0]["content"].is_array());
1528 let content_array = messages[0]["content"].as_array().unwrap();
1529 assert_eq!(content_array.len(), 5);
1530 assert_eq!(content_array[0]["type"], "text");
1531 assert_eq!(content_array[1]["type"], "audio");
1532 assert_eq!(content_array[2]["type"], "text");
1533 assert_eq!(content_array[3]["type"], "image");
1534 assert_eq!(content_array[4]["type"], "text");
1535
1536 let json_str = r#"{
1538 "model": "gpt-4o",
1539 "messages": [
1540 {
1541 "role": "user",
1542 "content": [
1543 {"type": "text", "text": "Check this:"},
1544 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1545 {"type": "text", "text": "Interesting?"}
1546 ]
1547 }
1548 ]
1549 }"#;
1550
1551 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1552 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1553 let messages =
1554 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1555
1556 assert!(messages[0]["content"].is_array());
1558 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1559 }
1560
1561 #[test]
1562 fn test_normalize_tool_arguments_tojson() {
1563 let tmpl = r#"{{ messages[0].tool_calls[0].function.arguments | tojson }}"#;
1564
1565 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1567 "role": "assistant",
1568 "tool_calls": [{
1569 "type": "function",
1570 "function": {
1571 "name": "get_current_weather",
1572 "arguments": "{\"format\":\"celsius\",\"location\":\"San Francisco, CA\"}"
1573 }
1574 }]
1575 })]);
1576
1577 normalize_tool_calls_arguments_in_messages(&mut messages);
1578
1579 let mut env = Environment::new();
1580 env.add_filter("tojson", super::super::tokcfg::tojson);
1581 env.add_template("t", tmpl).unwrap();
1582 let out = env
1583 .get_template("t")
1584 .unwrap()
1585 .render(context! { messages => messages.as_array().unwrap() })
1586 .unwrap();
1587
1588 assert_eq!(
1591 out,
1592 r#"{"format": "celsius", "location": "San Francisco, CA"}"#
1593 );
1594 }
1595
1596 #[test]
1597 fn test_normalize_tool_arguments_items_loop() {
1598 let tmpl = r#"{% for k, v in messages[0].tool_calls[0].function.arguments|items %}{{k}}={{v}};{% endfor %}"#;
1599
1600 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1601 "role": "assistant",
1602 "tool_calls": [{
1603 "type": "function",
1604 "function": {
1605 "name": "f",
1606 "arguments": "{\"a\":1,\"b\":\"x\"}"
1607 }
1608 }]
1609 })]);
1610
1611 normalize_tool_calls_arguments_in_messages(&mut messages);
1612
1613 let mut env = Environment::new();
1614 env.add_template("t", tmpl).unwrap();
1615 let out = env
1616 .get_template("t")
1617 .unwrap()
1618 .render(context! { messages => messages.as_array().unwrap() })
1619 .unwrap();
1620
1621 assert!(out == "a=1;b=x;" || out == "b=x;a=1;");
1622 }
1623
1624 #[test]
1625 fn test_normalize_tool_arguments_legacy_function_call() {
1626 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1628 "role": "assistant",
1629 "function_call": {
1630 "name": "get_weather",
1631 "arguments": "{\"location\":\"NYC\"}"
1632 }
1633 })]);
1634
1635 normalize_function_call_arguments_in_messages(&mut messages);
1636
1637 assert_eq!(
1638 messages[0]["function_call"]["arguments"],
1639 serde_json::json!({"location": "NYC"})
1640 );
1641 }
1642
1643 #[test]
1644 fn test_normalize_tool_arguments_malformed_json_passthrough() {
1645 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1647 "role": "assistant",
1648 "tool_calls": [{
1649 "type": "function",
1650 "function": {
1651 "name": "f",
1652 "arguments": "not valid json at all"
1653 }
1654 }]
1655 })]);
1656
1657 normalize_tool_calls_arguments_in_messages(&mut messages);
1658
1659 assert_eq!(
1660 messages[0]["tool_calls"][0]["function"]["arguments"],
1661 serde_json::Value::String("not valid json at all".to_string())
1662 );
1663 }
1664
1665 #[test]
1666 fn test_normalize_tool_arguments_with_multimodal_content() {
1667 let json_str = r#"{
1668 "model": "gpt-4o",
1669 "messages": [
1670 {
1671 "role": "user",
1672 "content": [
1673 {"type": "text", "text": "Check this:"},
1674 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1675 {"type": "text", "text": "Interesting?"}
1676 ]
1677 },
1678 {
1679 "role": "assistant",
1680 "tool_calls": [{
1681 "id": "call_123",
1682 "type": "function",
1683 "function": {
1684 "name": "analyze_video",
1685 "arguments": "{\"url\":\"https://example.com/vid.mp4\",\"format\":\"mp4\"}"
1686 }
1687 }]
1688 }
1689 ]
1690 }"#;
1691
1692 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1693 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1694
1695 let mut messages =
1697 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1698
1699 normalize_tool_calls_arguments_in_messages(&mut messages);
1700
1701 assert!(messages[0]["content"].is_array());
1703 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1704
1705 assert!(messages[1]["tool_calls"][0]["function"]["arguments"].is_object());
1707 assert_eq!(
1708 messages[1]["tool_calls"][0]["function"]["arguments"]["url"],
1709 "https://example.com/vid.mp4"
1710 );
1711 }
1712
1713 #[test]
1715 fn test_may_be_fix_msg_content_string_to_array() {
1716 let json_str = r#"{
1717 "model": "gpt-4o",
1718 "messages": [
1719 {
1720 "role": "user",
1721 "content": "Hello, how are you?"
1722 }
1723 ]
1724 }"#;
1725
1726 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1727 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1728
1729 let messages =
1731 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1732
1733 assert!(messages[0]["content"].is_array());
1735 let content_array = messages[0]["content"].as_array().unwrap();
1736 assert_eq!(content_array.len(), 1);
1737 assert_eq!(content_array[0]["type"], "text");
1738 assert_eq!(content_array[0]["text"], "Hello, how are you?");
1739 }
1740
1741 #[test]
1743 fn test_may_be_fix_msg_content_array_preserved_with_multimodal() {
1744 let json_str = r#"{
1745 "model": "gpt-4o",
1746 "messages": [
1747 {
1748 "role": "user",
1749 "content": [
1750 {"type": "text", "text": "part 1"},
1751 {"type": "text", "text": "part 2"}
1752 ]
1753 }
1754 ]
1755 }"#;
1756
1757 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1758 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1759
1760 let messages =
1762 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1763
1764 assert!(messages[0]["content"].is_array());
1766 let content_array = messages[0]["content"].as_array().unwrap();
1767 assert_eq!(content_array.len(), 2);
1768 assert_eq!(content_array[0]["text"], "part 1");
1769 assert_eq!(content_array[1]["text"], "part 2");
1770 }
1771
1772 fn user() -> Msg {
1773 Msg::User(Default::default())
1774 }
1775 fn tool() -> Msg {
1776 Msg::Tool(Default::default())
1777 }
1778
1779 fn dummy_state(messages: Vec<Msg>) -> NvCreateChatCompletionRequest {
1780 let json = serde_json::json!({
1781 "model": "test-model",
1782 "messages": messages
1783 });
1784 serde_json::from_value(json).unwrap()
1785 }
1786
1787 #[test]
1788 fn add_after_user() {
1789 let s = dummy_state(vec![user()]);
1790 assert!(s.should_add_generation_prompt());
1791 }
1792
1793 #[test]
1794 fn add_after_tool() {
1795 let s = dummy_state(vec![tool()]);
1796 assert!(s.should_add_generation_prompt());
1797 }
1798
1799 #[test]
1800 fn add_when_empty() {
1801 let s = dummy_state(vec![]);
1802 assert!(s.should_add_generation_prompt());
1803 }
1804
1805 fn tool_aware_formatter(
1807 exclude_tools_when_tool_choice_none: bool,
1808 ) -> HfTokenizerConfigJsonFormatter {
1809 let template = r#"
1810{%- if tools is iterable and tools | length > 0 %}
1811TOOL_MODE tools={{ tools | length }}
1812{%- else %}
1813NORMAL_MODE
1814{%- endif %}
1815{{ messages[0].content }}"#;
1816
1817 let chat_template: super::tokcfg::ChatTemplate =
1818 serde_json::from_value(serde_json::json!({ "chat_template": template })).unwrap();
1819
1820 HfTokenizerConfigJsonFormatter::with_options(
1821 chat_template,
1822 ContextMixins::new(&[]),
1823 exclude_tools_when_tool_choice_none,
1824 )
1825 .unwrap()
1826 }
1827
1828 fn gemma4_tool_template_for_tests() -> &'static str {
1829 r#"
1830{{ bos_token }}
1831{%- set loop_messages = messages -%}
1832{%- set ns_turn = namespace(last_user_idx=-1) -%}
1833{%- for i in range(loop_messages | length) -%}
1834 {%- if loop_messages[i]['role'] == 'user' -%}
1835 {%- set ns_turn.last_user_idx = i -%}
1836 {%- endif -%}
1837{%- endfor -%}
1838{%- for message in loop_messages -%}
1839 {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
1840 {{- '<|turn>' + role + '\n' }}
1841
1842 {%- if message.get('reasoning') and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
1843 {{- '<|channel>thought\n' + message['reasoning'] + '\n<channel|>'}}
1844 {%- endif -%}
1845
1846 {%- if message['tool_calls'] -%}
1847 {%- for tool_call in message['tool_calls'] -%}
1848 {%- set function = tool_call['function'] -%}
1849 {{- '<|tool_call>call:' + function['name'] + '{' -}}
1850 {%- if function['arguments'] is mapping -%}
1851 {%- set ns_args = namespace(found_first=false) -%}
1852 {%- for key, value in function['arguments'] | dictsort -%}
1853 {%- if ns_args.found_first %},{% endif -%}
1854 {%- set ns_args.found_first = true -%}
1855 {{- key -}}:{{- value -}}
1856 {%- endfor -%}
1857 {%- elif function['arguments'] is string -%}
1858 {{- function['arguments'] -}}
1859 {%- endif -%}
1860 {{- '}<tool_call|>' -}}
1861 {%- endfor -%}
1862 {%- endif -%}
1863
1864 {%- if message['content'] is string -%}
1865 {{- message['content'] -}}
1866 {%- endif -%}
1867 {{- '<turn|>\n' -}}
1868{%- endfor -%}
1869"#
1870 }
1871
1872 fn make_gemma4_tool_formatter_for_tests() -> HfTokenizerConfigJsonFormatter {
1873 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1874 "chat_template": gemma4_tool_template_for_tests()
1875 }))
1876 .unwrap();
1877 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
1878 }
1879
1880 fn request_with_tool_choice(tool_choice: &str) -> NvCreateChatCompletionRequest {
1882 serde_json::from_value(serde_json::json!({
1883 "model": "test",
1884 "messages": [{"role": "user", "content": "hello"}],
1885 "tools": [{
1886 "type": "function",
1887 "function": {
1888 "name": "get_weather",
1889 "description": "Get weather",
1890 "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}
1891 }
1892 }],
1893 "tool_choice": tool_choice
1894 }))
1895 .unwrap()
1896 }
1897
1898 #[test]
1899 fn test_exclude_tools_strips_when_tool_choice_none() {
1900 let formatter = tool_aware_formatter(true);
1901 let request = request_with_tool_choice("none");
1902 let result = formatter.render(&request).unwrap();
1903 assert!(
1904 result.contains("NORMAL_MODE"),
1905 "With exclude_tools=true and tool_choice=none, tools should be stripped. Got: {}",
1906 result
1907 );
1908 }
1909
1910 #[test]
1911 fn test_exclude_tools_keeps_when_tool_choice_auto() {
1912 let formatter = tool_aware_formatter(true);
1913 let request = request_with_tool_choice("auto");
1914 let result = formatter.render(&request).unwrap();
1915 assert!(
1916 result.contains("TOOL_MODE"),
1917 "With tool_choice=auto, tools should be included. Got: {}",
1918 result
1919 );
1920 }
1921
1922 #[test]
1923 fn test_no_exclude_tools_keeps_when_tool_choice_none() {
1924 let formatter = tool_aware_formatter(false);
1925 let request = request_with_tool_choice("none");
1926 let result = formatter.render(&request).unwrap();
1927 assert!(
1928 result.contains("TOOL_MODE"),
1929 "With exclude_tools=false and tool_choice=none, tools should NOT be stripped. Got: {}",
1930 result
1931 );
1932 }
1933
1934 #[test]
1935 fn test_inject_reasoning_content_segments_with_tool_calls() {
1936 let mut messages = serde_json::json!([
1938 {
1939 "role": "user",
1940 "content": "What is sqrt(144) and sqrt(256)?"
1941 },
1942 {
1943 "role": "assistant",
1944 "content": "Let me calculate those.",
1945 "reasoning_content": ["I need to compute sqrt(144)", "Now sqrt(256)", ""],
1946 "tool_calls": [
1947 {
1948 "id": "call_0",
1949 "type": "function",
1950 "function": {
1951 "name": "calculator",
1952 "arguments": "{\"expr\": \"sqrt(144)\"}"
1953 }
1954 },
1955 {
1956 "id": "call_1",
1957 "type": "function",
1958 "function": {
1959 "name": "calculator",
1960 "arguments": "{\"expr\": \"sqrt(256)\"}"
1961 }
1962 }
1963 ]
1964 }
1965 ]);
1966
1967 inject_reasoning_content_into_messages(&mut messages);
1968
1969 let assistant = &messages[1];
1970
1971 assert!(
1973 assistant.get("reasoning_content").is_none(),
1974 "reasoning_content should be removed after injection"
1975 );
1976
1977 let content = assistant["content"].as_str().unwrap();
1979 assert!(
1980 content.starts_with("<think>I need to compute sqrt(144)</think>"),
1981 "content should start with first reasoning segment, got: {}",
1982 content
1983 );
1984 assert!(
1985 content.contains("<think>Now sqrt(256)</think>"),
1986 "content should contain second reasoning segment"
1987 );
1988 assert!(
1990 !content.contains("<think></think>"),
1991 "empty segments should be skipped"
1992 );
1993 assert!(
1995 content.ends_with("Let me calculate those."),
1996 "original content should be at the end, got: {}",
1997 content
1998 );
1999
2000 assert!(assistant.get("tool_calls").is_some());
2002 assert_eq!(assistant["tool_calls"].as_array().unwrap().len(), 2);
2003 }
2004
2005 #[test]
2006 fn test_gemma4_template_renders_reasoning_content_segments_around_tool_calls() {
2007 let formatter = make_gemma4_tool_formatter_for_tests();
2008 assert!(
2009 formatter.tool_use_template_handles_reasoning,
2010 "Gemma4 template adaptation should make reasoning_content native"
2011 );
2012
2013 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2014 "model": "gemma4-test",
2015 "messages": [
2016 {"role": "user", "content": "inspect two things"},
2017 {
2018 "role": "assistant",
2019 "content": null,
2020 "reasoning_content": [
2021 "Think before the first call.",
2022 "Think before the second call.",
2023 "Think after both calls."
2024 ],
2025 "tool_calls": [
2026 {
2027 "id": "call_0",
2028 "type": "function",
2029 "function": {
2030 "name": "first_tool",
2031 "arguments": "{\"path\":\".\"}"
2032 }
2033 },
2034 {
2035 "id": "call_1",
2036 "type": "function",
2037 "function": {
2038 "name": "second_tool",
2039 "arguments": "{\"path\":\"/tmp\"}"
2040 }
2041 }
2042 ]
2043 }
2044 ]
2045 }))
2046 .unwrap();
2047
2048 let rendered = formatter.render(&request).unwrap();
2049
2050 let expected = concat!(
2051 "<|channel>thought\nThink before the first call.\n<channel|>",
2052 "<|tool_call>call:first_tool{path:.}<tool_call|>",
2053 "<|channel>thought\nThink before the second call.\n<channel|>",
2054 "<|tool_call>call:second_tool{path:/tmp}<tool_call|>",
2055 "<|channel>thought\nThink after both calls.\n<channel|>"
2056 );
2057 assert!(
2058 rendered.contains(expected),
2059 "Gemma4 reasoning segments should stay adjacent to their tool calls, got: {rendered}"
2060 );
2061 assert!(!rendered.contains("<think>"));
2062 assert!(!rendered.contains("reasoning_content"));
2063 }
2064
2065 #[test]
2066 fn test_gemma4_template_renders_reasoning_content_without_tool_calls() {
2067 let formatter = make_gemma4_tool_formatter_for_tests();
2068 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2069 "model": "gemma4-test",
2070 "messages": [
2071 {"role": "user", "content": "answer directly"},
2072 {
2073 "role": "assistant",
2074 "content": "Direct answer.",
2075 "reasoning_content": "Private thought."
2076 }
2077 ]
2078 }))
2079 .unwrap();
2080
2081 let rendered = formatter.render(&request).unwrap();
2082
2083 assert!(
2084 rendered.contains("<|channel>thought\nPrivate thought.\n<channel|>Direct answer."),
2085 "Gemma4 reasoning_content should render in the thought channel, got: {rendered}"
2086 );
2087 assert!(!rendered.contains("<think>"));
2088 assert!(!rendered.contains("reasoning_content"));
2089 }
2090
2091 #[test]
2097 fn test_reasoning_flag_is_per_template_not_global() {
2098 const PLAIN_DEFAULT: &str = "{{ bos_token }}{%- for message in messages -%}\
2101 {{ message['role'] }}: {{ message['content'] }}\n{%- endfor -%}";
2102
2103 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2104 "chat_template": [
2105 {"default": PLAIN_DEFAULT},
2106 {"tool_use": gemma4_tool_template_for_tests()},
2107 ]
2108 }))
2109 .unwrap();
2110 let formatter =
2111 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2112
2113 assert!(
2117 formatter.tool_use_template_handles_reasoning,
2118 "adapted gemma4 tool_use template should handle reasoning natively"
2119 );
2120 assert!(
2121 !formatter.default_template_handles_reasoning,
2122 "plain default template does not reference reasoning_content"
2123 );
2124
2125 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2128 "model": "gemma4-test",
2129 "messages": [
2130 {"role": "user", "content": "answer directly"},
2131 {
2132 "role": "assistant",
2133 "content": "Direct answer.",
2134 "reasoning_content": "Private thought."
2135 }
2136 ]
2137 }))
2138 .unwrap();
2139
2140 let rendered = formatter.render(&request).unwrap();
2141 assert!(
2142 rendered.contains("<think>Private thought.</think>Direct answer."),
2143 "reasoning must be injected on the no-tool default path, got: {rendered}"
2144 );
2145 }
2146
2147 #[test]
2148 fn test_inject_reasoning_content_text_variant() {
2149 let mut messages = serde_json::json!([
2150 {
2151 "role": "assistant",
2152 "content": "The answer is 42.",
2153 "reasoning_content": "Let me think about this carefully."
2154 }
2155 ]);
2156
2157 inject_reasoning_content_into_messages(&mut messages);
2158
2159 let assistant = &messages[0];
2160 assert!(assistant.get("reasoning_content").is_none());
2161 let content = assistant["content"].as_str().unwrap();
2162 assert_eq!(
2163 content,
2164 "<think>Let me think about this carefully.</think>The answer is 42."
2165 );
2166 }
2167
2168 #[test]
2169 fn test_inject_reasoning_content_null_content() {
2170 let mut messages = serde_json::json!([
2172 {
2173 "role": "assistant",
2174 "content": null,
2175 "reasoning_content": "Thinking...",
2176 "tool_calls": [{"id": "call_0", "type": "function", "function": {"name": "f", "arguments": "{}"}}]
2177 }
2178 ]);
2179
2180 inject_reasoning_content_into_messages(&mut messages);
2181
2182 let content = messages[0]["content"].as_str().unwrap();
2183 assert_eq!(content, "<think>Thinking...</think>");
2184 assert!(messages[0].get("reasoning_content").is_none());
2185 }
2186
2187 #[test]
2188 fn test_inject_reasoning_content_skips_non_assistant() {
2189 let mut messages = serde_json::json!([
2190 {
2191 "role": "user",
2192 "content": "hello",
2193 "reasoning_content": "should not be touched"
2194 }
2195 ]);
2196
2197 inject_reasoning_content_into_messages(&mut messages);
2198
2199 assert!(messages[0].get("reasoning_content").is_some());
2201 }
2202
2203 fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
2205 use super::tokcfg::ChatTemplate;
2206 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2207
2208 let template = r#"{%- for message in messages %}{{ message.role }}: {{ message.content }}
2211{%- endfor %}
2212{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2213
2214 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2215 "chat_template": template
2216 }))
2217 .unwrap();
2218
2219 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2220 }
2221
2222 #[test]
2225 fn test_reasoning_content_text_roundtrip_render() {
2226 use super::OAIPromptFormatter;
2227 let formatter = make_test_formatter();
2228
2229 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2230 "model": "test-model",
2231 "messages": [
2232 {"role": "user", "content": "What is sqrt(144)?"},
2233 {
2234 "role": "assistant",
2235 "content": "The answer is 12.",
2236 "reasoning_content": "I need to compute the square root of 144."
2237 },
2238 {"role": "user", "content": "Are you sure?"}
2239 ]
2240 }))
2241 .unwrap();
2242
2243 let rendered = formatter.render(&request).unwrap();
2244
2245 assert!(
2246 rendered.contains("<think>I need to compute the square root of 144.</think>"),
2247 "reasoning_content must appear as <think> block, got: {}",
2248 rendered
2249 );
2250 assert!(
2251 rendered.contains("The answer is 12."),
2252 "original content must be preserved"
2253 );
2254 assert!(
2255 !rendered.contains("reasoning_content"),
2256 "raw reasoning_content field should not leak into prompt"
2257 );
2258 }
2259
2260 #[test]
2264 fn test_reasoning_content_agentic_tool_call_roundtrip_render() {
2265 use super::OAIPromptFormatter;
2266 let formatter = make_test_formatter();
2267
2268 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2269 "model": "test-model",
2270 "messages": [
2271 {"role": "user", "content": "What is sqrt(144) + sqrt(256)?"},
2272 {
2273 "role": "assistant",
2274 "content": null,
2275 "reasoning_content": "I need to compute both square roots. Let me start with sqrt(144).",
2276 "tool_calls": [{
2277 "id": "call_0",
2278 "type": "function",
2279 "function": {
2280 "name": "calculator",
2281 "arguments": "{\"expr\": \"sqrt(144)\"}"
2282 }
2283 }]
2284 },
2285 {
2286 "role": "tool",
2287 "tool_call_id": "call_0",
2288 "content": "12"
2289 },
2290 {
2291 "role": "assistant",
2292 "content": "sqrt(144) = 12 and sqrt(256) = 16, so the answer is 28.",
2293 "reasoning_content": "Got 12 for sqrt(144). Now sqrt(256) = 16. Sum is 28."
2294 },
2295 {"role": "user", "content": "Thanks!"}
2296 ]
2297 }))
2298 .unwrap();
2299
2300 let rendered = formatter.render(&request).unwrap();
2301
2302 assert!(
2304 rendered.contains("<think>I need to compute both square roots"),
2305 "first turn reasoning must be in prompt, got: {}",
2306 rendered
2307 );
2308 assert!(
2310 rendered.contains("<think>Got 12 for sqrt(144)"),
2311 "second turn reasoning must be in prompt"
2312 );
2313 assert!(
2314 rendered.contains("the answer is 28"),
2315 "final answer content must be preserved"
2316 );
2317 assert!(
2319 !rendered.contains("reasoning_content"),
2320 "raw reasoning_content field should not leak into prompt"
2321 );
2322 }
2323
2324 #[test]
2326 fn test_reasoning_injected_when_template_ignores_it() {
2327 use super::OAIPromptFormatter;
2328 let formatter = make_test_formatter();
2329
2330 assert!(!formatter.default_template_handles_reasoning);
2332 assert!(!formatter.tool_use_template_handles_reasoning);
2333
2334 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2335 "model": "test-model",
2336 "messages": [
2337 {"role": "user", "content": "Hello"},
2338 {
2339 "role": "assistant",
2340 "content": "Hi.",
2341 "reasoning_content": "The user said hello."
2342 },
2343 {"role": "user", "content": "Bye"}
2344 ]
2345 }))
2346 .unwrap();
2347
2348 let rendered = formatter.render(&request).unwrap();
2349 assert!(
2350 rendered.contains("<think>The user said hello.</think>"),
2351 "injection must happen when template ignores reasoning_content, got: {}",
2352 rendered
2353 );
2354 }
2355
2356 #[test]
2358 fn test_reasoning_not_injected_when_template_handles_it() {
2359 use super::tokcfg::ChatTemplate;
2360 use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
2361
2362 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>
2364{%- endif %}{{ message.role }}: {{ message.content }}
2365{%- endfor %}
2366{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2367
2368 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2369 "chat_template": template
2370 }))
2371 .unwrap();
2372
2373 let formatter =
2374 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2375
2376 assert!(formatter.default_template_handles_reasoning);
2378 assert!(formatter.tool_use_template_handles_reasoning);
2379
2380 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2381 "model": "test-model",
2382 "messages": [
2383 {"role": "user", "content": "Hello"},
2384 {
2385 "role": "assistant",
2386 "content": "Hi.",
2387 "reasoning_content": "The user said hello."
2388 },
2389 {"role": "user", "content": "Bye"}
2390 ]
2391 }))
2392 .unwrap();
2393
2394 let rendered = formatter.render(&request).unwrap();
2395
2396 assert!(
2398 rendered.contains("<think>The user said hello.</think>"),
2399 "template must render reasoning_content natively, got: {}",
2400 rendered
2401 );
2402 let think_count = rendered.matches("<think>").count();
2404 assert_eq!(
2405 think_count, 1,
2406 "must have exactly one <think> block (from template), got {} in: {}",
2407 think_count, rendered
2408 );
2409 }
2410
2411 const QWEN3_THINKING_TEMPLATE: &str = r##"{%- if tools %}
2415 {{- '<|im_start|>system\n' }}
2416 {%- if messages[0].role == 'system' %}
2417 {{- messages[0].content + '\n\n' }}
2418 {%- endif %}
2419 {{- "# 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>" }}
2420 {%- for tool in tools %}
2421 {{- "\n" }}
2422 {{- tool | tojson }}
2423 {%- endfor %}
2424 {{- "\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" }}
2425{%- else %}
2426 {%- if messages[0].role == 'system' %}
2427 {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
2428 {%- endif %}
2429{%- endif %}
2430{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
2431{%- for message in messages[::-1] %}
2432 {%- set index = (messages|length - 1) - loop.index0 %}
2433 {%- 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>')) %}
2434 {%- set ns.multi_step_tool = false %}
2435 {%- set ns.last_query_index = index %}
2436 {%- endif %}
2437{%- endfor %}
2438{%- for message in messages %}
2439 {%- if message.content is string %}
2440 {%- set content = message.content %}
2441 {%- else %}
2442 {%- set content = '' %}
2443 {%- endif %}
2444 {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
2445 {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
2446 {%- elif message.role == "assistant" %}
2447 {%- set reasoning_content = '' %}
2448 {%- if message.reasoning_content is string %}
2449 {%- set reasoning_content = message.reasoning_content %}
2450 {%- else %}
2451 {%- if '</think>' in content %}
2452 {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
2453 {%- set content = content.split('</think>')[-1].lstrip('\n') %}
2454 {%- endif %}
2455 {%- endif %}
2456 {%- if loop.index0 > ns.last_query_index %}
2457 {%- if loop.last or (not loop.last and reasoning_content) %}
2458 {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
2459 {%- else %}
2460 {{- '<|im_start|>' + message.role + '\n' + content }}
2461 {%- endif %}
2462 {%- else %}
2463 {{- '<|im_start|>' + message.role + '\n' + content }}
2464 {%- endif %}
2465 {%- if message.tool_calls %}
2466 {%- for tool_call in message.tool_calls %}
2467 {%- if (loop.first and content) or (not loop.first) %}
2468 {{- '\n' }}
2469 {%- endif %}
2470 {%- if tool_call.function %}
2471 {%- set tool_call = tool_call.function %}
2472 {%- endif %}
2473 {{- '<tool_call>\n{"name": "' }}
2474 {{- tool_call.name }}
2475 {{- '", "arguments": ' }}
2476 {%- if tool_call.arguments is string %}
2477 {{- tool_call.arguments }}
2478 {%- else %}
2479 {{- tool_call.arguments | tojson }}
2480 {%- endif %}
2481 {{- '}\n</tool_call>' }}
2482 {%- endfor %}
2483 {%- endif %}
2484 {{- '<|im_end|>\n' }}
2485 {%- elif message.role == "tool" %}
2486 {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
2487 {{- '<|im_start|>user' }}
2488 {%- endif %}
2489 {{- '\n<tool_response>\n' }}
2490 {{- content }}
2491 {{- '\n</tool_response>' }}
2492 {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
2493 {{- '<|im_end|>\n' }}
2494 {%- endif %}
2495 {%- endif %}
2496{%- endfor %}
2497{%- if add_generation_prompt %}
2498 {{- '<|im_start|>assistant\n<think>\n' }}
2499{%- endif %}"##;
2500
2501 fn qwen3_thinking_formatter() -> HfTokenizerConfigJsonFormatter {
2502 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2503 "chat_template": QWEN3_THINKING_TEMPLATE,
2504 }))
2505 .unwrap();
2506 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2507 }
2508
2509 #[test]
2510 fn test_qwen3_thinking_template_flags_detected() {
2511 let formatter = qwen3_thinking_formatter();
2512 assert!(
2513 formatter.tool_use_template_handles_reasoning,
2514 "template references reasoning_content directly"
2515 );
2516 assert!(
2519 formatter.default_template_handles_tool_calls_arguments_string,
2520 "default template branches on `arguments is string`"
2521 );
2522 assert!(
2523 formatter.tool_use_template_handles_tool_calls_arguments_string,
2524 "tool_use template branches on `arguments is string`"
2525 );
2526 }
2527
2528 #[test]
2538 fn test_qwen3_thinking_append_only_across_tool_use_turn() {
2539 let formatter = qwen3_thinking_formatter();
2540
2541 let tools = serde_json::json!([{
2542 "type": "function",
2543 "function": {
2544 "name": "get_weather",
2545 "description": "Get the current weather for a location",
2546 "parameters": {
2547 "type": "object",
2548 "properties": {
2549 "location": {"type": "string"},
2550 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
2551 },
2552 "required": ["location"]
2553 }
2554 }
2555 }]);
2556
2557 let turn1_request: NvCreateChatCompletionRequest =
2559 serde_json::from_value(serde_json::json!({
2560 "model": "qwen3-thinking",
2561 "messages": [
2562 {"role": "system", "content": "You are a helpful assistant."},
2563 {"role": "user", "content": "What's the weather in San Francisco?"},
2564 ],
2565 "tools": tools,
2566 }))
2567 .unwrap();
2568 let p1 = formatter.render(&turn1_request).unwrap();
2569
2570 let model_emitted = "I'll call get_weather for SF.\n\
2574 </think>\n\n\
2575 <tool_call>\n\
2576 {\"name\": \"get_weather\", \"arguments\": {\"location\": \"San Francisco\", \"unit\": \"celsius\"}}\n\
2577 </tool_call><|im_end|>\n";
2578 let wire_after_t1 = format!("{p1}{model_emitted}");
2579
2580 let turn2_request: NvCreateChatCompletionRequest =
2583 serde_json::from_value(serde_json::json!({
2584 "model": "qwen3-thinking",
2585 "messages": [
2586 {"role": "system", "content": "You are a helpful assistant."},
2587 {"role": "user", "content": "What's the weather in San Francisco?"},
2588 {
2589 "role": "assistant",
2590 "content": "",
2591 "reasoning_content": "I'll call get_weather for SF.",
2592 "tool_calls": [{
2593 "id": "call_sf",
2594 "type": "function",
2595 "function": {
2596 "name": "get_weather",
2597 "arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
2598 }
2599 }]
2600 },
2601 {
2602 "role": "tool",
2603 "tool_call_id": "call_sf",
2604 "content": "{\"temp\": 18, \"conditions\": \"Foggy\"}"
2605 }
2606 ],
2607 "tools": tools,
2608 }))
2609 .unwrap();
2610 let p2 = formatter.render(&turn2_request).unwrap();
2611
2612 if !p2.starts_with(&wire_after_t1) {
2613 let div = wire_after_t1
2615 .as_bytes()
2616 .iter()
2617 .zip(p2.as_bytes())
2618 .position(|(a, b)| a != b)
2619 .unwrap_or_else(|| wire_after_t1.len().min(p2.len()));
2620 let lo = div.saturating_sub(40);
2621 panic!(
2622 "turn-2 prompt is NOT a prefix-extension of [turn-1 + model bytes]\n \
2623 diverges at byte {div}\n \
2624 wire ends: ...{}|{}\n \
2625 t2 has: ...{}|{}",
2626 String::from_utf8_lossy(&wire_after_t1.as_bytes()[lo..div]),
2627 String::from_utf8_lossy(
2628 &wire_after_t1.as_bytes()[div..(div + 60).min(wire_after_t1.len())]
2629 ),
2630 String::from_utf8_lossy(&p2.as_bytes()[lo..div]),
2631 String::from_utf8_lossy(&p2.as_bytes()[div..(div + 60).min(p2.len())]),
2632 );
2633 }
2634
2635 let suffix = &p2[wire_after_t1.len()..];
2638 assert!(
2639 suffix.contains("<tool_response>"),
2640 "appended bytes must include the tool response, got: {suffix}"
2641 );
2642 assert!(
2643 suffix.ends_with("<|im_start|>assistant\n<think>\n"),
2644 "appended bytes must end with the next generation prompt, got: {suffix}"
2645 );
2646 }
2647}