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 render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
441 let mixins = Value::from_dyn_object(self.mixins.clone());
442
443 let tools = req.tools();
444 let tools = if self.exclude_tools_when_tool_choice_none {
447 match req.tool_choice() {
448 Some(ref tc) if tc.as_str() == Some("none") => None,
449 _ => tools,
450 }
451 } else {
452 tools
453 };
454 let has_tools = tools.as_ref().and_then(|v| v.len()).is_some_and(|l| l > 0);
456 let add_generation_prompt = req.should_add_generation_prompt();
457
458 tracing::trace!(
459 "Rendering prompt with tools: {:?}, add_generation_prompt: {}",
460 has_tools,
461 add_generation_prompt
462 );
463
464 let messages_canonical = req.messages();
465 let mut messages_for_template: serde_json::Value =
466 serde_json::to_value(&messages_canonical).unwrap();
467
468 messages_for_template = serde_json::to_value(may_be_fix_msg_content(
469 messages_for_template,
470 self.requires_content_arrays,
471 self.image_placeholder_template,
472 ))
473 .unwrap();
474
475 let (template_name, template_handles_tool_calls_args_string, template_handles_reasoning) =
482 if has_tools {
483 (
484 "tool_use",
485 self.tool_use_template_handles_tool_calls_arguments_string,
486 self.tool_use_template_handles_reasoning,
487 )
488 } else {
489 (
490 "default",
491 self.default_template_handles_tool_calls_arguments_string,
492 self.default_template_handles_reasoning,
493 )
494 };
495
496 if !template_handles_tool_calls_args_string {
503 normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
504 }
505 normalize_function_call_arguments_in_messages(&mut messages_for_template);
509
510 if !template_handles_reasoning {
515 inject_reasoning_content_into_messages(&mut messages_for_template);
516 }
517
518 let ctx = context! {
519 messages => messages_for_template,
520 tools => tools,
521 bos_token => self.config.bos_tok(),
522 eos_token => self.config.eos_tok(),
523 unk_token => self.config.unk_tok(),
524 add_generation_prompt => add_generation_prompt,
525 ..mixins
526 };
527
528 let ctx = if let Some(args) = req.chat_template_args() {
530 let extra = Value::from_serialize(args);
531 context! { ..ctx, ..extra }
532 } else {
533 ctx
534 };
535
536 let tmpl: minijinja::Template<'_, '_> = self.env.get_template(template_name)?;
537 Ok(tmpl.render(&ctx)?)
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use dynamo_protocols::types::ChatCompletionRequestMessage as Msg;
545 use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
548 use minijinja::{Environment, context};
549
550 #[test]
558 fn test_render_long_conversation_does_not_overflow_stack() {
559 let handle = std::thread::Builder::new()
560 .stack_size(2 * 1024 * 1024)
561 .spawn(|| {
562 let template_string = concat!(
563 "{%- set ns = namespace(items=[]) -%}",
564 "{%- for m in messages -%}",
565 "{%- set ns.items = ns.items + [m] -%}",
566 "{%- endfor -%}",
567 "COUNT={{ ns.items | length }}"
568 );
569 let chat_template: ChatTemplate =
570 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
571 .unwrap();
572 let formatter =
573 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[]))
574 .unwrap();
575
576 let n = 3000;
577 let messages: Vec<serde_json::Value> = (0..n)
578 .map(|i| serde_json::json!({"role": "user", "content": format!("turn {i}")}))
579 .collect();
580 let request: NvCreateChatCompletionRequest =
581 serde_json::from_value(serde_json::json!({
582 "model": "test",
583 "messages": messages,
584 }))
585 .unwrap();
586
587 let rendered = formatter.render(&request).unwrap();
589 assert_eq!(rendered.trim(), format!("COUNT={n}"));
590 })
591 .unwrap();
592 handle.join().unwrap();
593 }
594
595 #[test]
606 #[ignore]
607 fn dump_gptoss_tool_prompt() {
608 use super::tokcfg::ChatTemplate;
609 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
610
611 let path = std::env::var("GPTOSS_CHAT_TEMPLATE").expect(
612 "set GPTOSS_CHAT_TEMPLATE to the tokenizer_config.json, chat_template.jinja, or model dir path",
613 );
614 let input_path = std::path::Path::new(&path);
615 let file_path = if input_path.is_dir() {
616 input_path.join("tokenizer_config.json")
618 } else {
619 input_path.to_path_buf()
620 };
621 let raw = std::fs::read_to_string(&file_path).expect("read chat template file");
622 let template_string: String = match serde_json::from_str::<serde_json::Value>(&raw) {
629 Ok(v) if v.get("chat_template").is_some() => v["chat_template"]
630 .as_str()
631 .expect("chat_template field must be a string")
632 .to_string(),
633 _ => {
634 let sibling = std::path::Path::new(&path)
635 .parent()
636 .map(|d| d.join("chat_template.jinja"));
637 match sibling {
638 Some(p) if p.exists() => {
639 eprintln!(
640 "[info] {path} had no chat_template field; using {}",
641 p.display()
642 );
643 std::fs::read_to_string(&p).expect("read sibling chat_template.jinja")
644 }
645 _ => raw,
646 }
647 }
648 };
649
650 assert!(
653 template_string.contains("{%") || template_string.contains("{{"),
654 "resolved template has no Jinja tags — GPTOSS_CHAT_TEMPLATE ({path}) is probably \
655 tokenizer_config.json with no chat_template field and no sibling chat_template.jinja. \
656 Point it at the chat_template.jinja file."
657 );
658
659 let chat_template: ChatTemplate =
660 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
661 .unwrap();
662
663 let formatter =
664 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
665
666 let request: NvCreateChatCompletionRequest = serde_json::from_str(
668 r#"{
669 "model": "openai/gpt-oss-120b",
670 "messages": [{"role":"user","content":"Search the repo for the string \"countHook\"."}],
671 "tools": [
672 {"type":"function","function":{"name":"grep","description":"search files","parameters":{"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}}},
673 {"type":"function","function":{"name":"read","description":"read a file","parameters":{"type":"object","properties":{"filePath":{"type":"string"}},"required":["filePath"]}}}
674 ]
675 }"#,
676 )
677 .unwrap();
678
679 let rendered = formatter.render(&request).unwrap();
680 eprintln!("================ RENDERED gpt-oss PROMPT (tools declared) ================");
681 eprintln!("{rendered}");
682 eprintln!("================ END RENDERED PROMPT ================");
683 eprintln!("[diagnostics] does the rendered prompt contain…");
684 for needle in [
685 "commentary",
686 "Calls to these tools",
687 "functions",
688 "# Tools",
689 "<|channel|>",
690 "constrain",
691 "analysis",
692 ] {
693 eprintln!(
694 " {:>22}: {}",
695 format!("{needle:?}"),
696 rendered.contains(needle)
697 );
698 }
699 }
700
701 #[test]
703 fn test_convert_media_url_to_placeholder_single_type() {
704 let content_array = vec![
705 serde_json::json!({"type": "text", "text": "Check this image:"}),
706 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
707 serde_json::json!({"type": "text", "text": "What do you see?"}),
708 ];
709
710 let conversions = &[("image_url", "image")];
711 let result = convert_media_url_to_placeholder(&content_array, conversions);
712
713 assert_eq!(result.len(), 3);
714 assert_eq!(result[0]["type"], "text");
716 assert_eq!(result[0]["text"], "Check this image:");
717 assert_eq!(result[1]["type"], "image");
719 assert!(result[1].get("image_url").is_none());
720 assert_eq!(result[2]["type"], "text");
722 assert_eq!(result[2]["text"], "What do you see?");
723 }
724
725 #[test]
727 fn test_convert_media_url_to_placeholder_multiple_same_type() {
728 let content_array = vec![
729 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}}),
730 serde_json::json!({"type": "text", "text": "vs"}),
731 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}),
732 ];
733
734 let conversions = &[("image_url", "image")];
735 let result = convert_media_url_to_placeholder(&content_array, conversions);
736
737 assert_eq!(result.len(), 3);
738 assert_eq!(result[0]["type"], "image");
739 assert_eq!(result[1]["type"], "text");
740 assert_eq!(result[2]["type"], "image");
741 }
742
743 #[test]
745 fn test_convert_media_url_to_placeholder_selective_conversion() {
746 let content_array = vec![
747 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
748 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
749 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
750 ];
751
752 let conversions = &[("image_url", "image")];
754 let result = convert_media_url_to_placeholder(&content_array, conversions);
755
756 assert_eq!(result.len(), 3);
757 assert_eq!(result[0]["type"], "audio_url");
759 assert!(result[0].get("audio_url").is_some());
760 assert_eq!(result[1]["type"], "video_url");
761 assert!(result[1].get("video_url").is_some());
762 assert_eq!(result[2]["type"], "image");
764 assert!(result[2].get("image_url").is_none());
765 }
766
767 #[test]
769 fn test_convert_media_url_to_placeholder_multiple_types() {
770 let content_array = vec![
771 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
772 serde_json::json!({"type": "text", "text": "and listen to"}),
773 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
774 serde_json::json!({"type": "text", "text": "and watch"}),
775 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
776 ];
777
778 let conversions = &[
780 ("image_url", "image"),
781 ("audio_url", "audio"),
782 ("video_url", "video"),
783 ];
784 let result = convert_media_url_to_placeholder(&content_array, conversions);
785
786 assert_eq!(result.len(), 5);
787 assert_eq!(result[0]["type"], "image");
788 assert!(result[0].get("image_url").is_none());
789 assert_eq!(result[1]["type"], "text");
790 assert_eq!(result[2]["type"], "audio");
791 assert!(result[2].get("audio_url").is_none());
792 assert_eq!(result[3]["type"], "text");
793 assert_eq!(result[4]["type"], "video");
794 assert!(result[4].get("video_url").is_none());
795 }
796
797 #[test]
799 fn test_convert_media_url_to_placeholder_no_conversions() {
800 let content_array = vec![
801 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
802 serde_json::json!({"type": "text", "text": "hello"}),
803 ];
804
805 let conversions: &[(&str, &str)] = &[];
806 let result = convert_media_url_to_placeholder(&content_array, conversions);
807
808 assert_eq!(result.len(), 2);
809 assert_eq!(result[0]["type"], "image_url");
811 assert!(result[0].get("image_url").is_some());
812 assert_eq!(result[1]["type"], "text");
813 }
814
815 #[test]
818 fn test_default_media_type_conversions_only_converts_image_url() {
819 let content_array = vec![
820 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
821 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
822 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
823 serde_json::json!({"type": "text", "text": "hello"}),
824 ];
825
826 let result =
828 convert_media_url_to_placeholder(&content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
829
830 assert_eq!(result.len(), 4);
831
832 assert_eq!(result[0]["type"], "image");
834 assert!(result[0].get("image_url").is_none());
835
836 assert_eq!(result[1]["type"], "video");
838 assert!(result[1].get("video_url").is_none());
839
840 assert_eq!(result[2]["type"], "audio");
842 assert!(result[2].get("audio_url").is_none());
843
844 assert_eq!(result[3]["type"], "text");
846 assert_eq!(result[3]["text"], "hello");
847 }
848
849 #[test]
850 fn test_may_be_fix_tool_schema_missing_type_and_properties() {
851 let json_str = r#"{
852 "model": "gpt-4o",
853 "messages": [],
854 "tools": [
855 {
856 "type": "function",
857 "function": {
858 "name": "get_weather",
859 "description": "Get the current weather in a given location",
860 "parameters": {},
861 "strict": null
862 }
863 }
864 ]
865 }"#;
866
867 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
868 let tools = serde_json::to_value(request.tools()).unwrap();
869
870 assert!(tools[0]["function"]["parameters"]["type"] == "object");
871 assert!(
872 tools[0]["function"]["parameters"]["properties"]
873 == serde_json::Value::Object(Default::default())
874 );
875 }
876
877 #[test]
878 fn test_may_be_fix_tool_schema_missing_type() {
879 let json_str = r#"{
880 "model": "gpt-4o",
881 "messages": [],
882 "tools": [
883 {
884 "type": "function",
885 "function": {
886 "name": "get_weather",
887 "description": "Get the current weather in a given location",
888 "parameters": {
889 "properties": {
890 "location": {
891 "type": "string",
892 "description": "City and state, e.g., 'San Francisco, CA'"
893 }
894 }
895 },
896 "strict": null
897 }
898 }
899 ]
900 }"#;
901 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
902
903 let tools = serde_json::to_value(request.tools()).unwrap();
904
905 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
906
907 let mut expected_properties = serde_json::Map::new();
908 let mut location = serde_json::Map::new();
909 location.insert(
910 "type".to_string(),
911 serde_json::Value::String("string".to_string()),
912 );
913 location.insert(
914 "description".to_string(),
915 serde_json::Value::String("City and state, e.g., 'San Francisco, CA'".to_string()),
916 );
917 expected_properties.insert("location".to_string(), serde_json::Value::Object(location));
918
919 assert_eq!(
920 tools[0]["function"]["parameters"]["properties"],
921 serde_json::Value::Object(expected_properties)
922 );
923 }
924
925 #[test]
926 fn test_may_be_fix_tool_schema_missing_properties() {
927 let json_str = r#"{
928 "model": "gpt-4o",
929 "messages": [],
930 "tools": [
931 {
932 "type": "function",
933 "function": {
934 "name": "get_weather",
935 "description": "Get the current weather in a given location",
936 "parameters": {"type": "object"},
937 "strict": null
938 }
939 }
940 ]
941 }"#;
942
943 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
944 let tools = serde_json::to_value(request.tools()).unwrap();
945
946 assert_eq!(
947 tools[0]["function"]["parameters"]["properties"],
948 serde_json::Value::Object(Default::default())
949 );
950 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
951 }
952
953 #[test]
954 fn test_may_be_fix_tool_schema_missing_description() {
955 let json_str = r#"{
959 "model": "gpt-4o",
960 "messages": [],
961 "tools": [
962 {
963 "type": "function",
964 "function": {
965 "name": "noop",
966 "parameters": {
967 "type": "object",
968 "properties": { "x": { "type": "string" } },
969 "required": ["x"],
970 "additionalProperties": false
971 },
972 "strict": null
973 }
974 }
975 ]
976 }"#;
977
978 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
979 let tools = serde_json::to_value(request.tools()).unwrap();
980
981 assert_eq!(
982 tools[0]["function"]["description"],
983 serde_json::Value::String(String::new())
984 );
985 }
986
987 #[test]
988 fn test_may_be_fix_tool_schema_null_description() {
989 let json_str = r#"{
991 "model": "gpt-4o",
992 "messages": [],
993 "tools": [
994 {
995 "type": "function",
996 "function": {
997 "name": "noop",
998 "description": null,
999 "parameters": {"type": "object", "properties": {}},
1000 "strict": null
1001 }
1002 }
1003 ]
1004 }"#;
1005
1006 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1007 let tools = serde_json::to_value(request.tools()).unwrap();
1008
1009 assert_eq!(
1010 tools[0]["function"]["description"],
1011 serde_json::Value::String(String::new())
1012 );
1013 }
1014
1015 #[test]
1016 fn test_may_be_fix_tool_schema_preserves_description() {
1017 let json_str = r#"{
1019 "model": "gpt-4o",
1020 "messages": [],
1021 "tools": [
1022 {
1023 "type": "function",
1024 "function": {
1025 "name": "get_weather",
1026 "description": "Get the current weather in a given location",
1027 "parameters": {"type": "object", "properties": {}},
1028 "strict": null
1029 }
1030 }
1031 ]
1032 }"#;
1033
1034 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1035 let tools = serde_json::to_value(request.tools()).unwrap();
1036
1037 assert_eq!(
1038 tools[0]["function"]["description"],
1039 "Get the current weather in a given location"
1040 );
1041 }
1042
1043 #[test]
1045 fn test_may_be_fix_msg_content_user_multipart() {
1046 let json_str = r#"{
1047 "model": "gpt-4o",
1048 "messages": [
1049 {
1050 "role": "user",
1051 "content": [
1052 {"type": "text", "text": "part 1"},
1053 {"type": "text", "text": "part 2"}
1054 ]
1055 }
1056 ]
1057 }"#;
1058
1059 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1060 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1061
1062 let messages =
1064 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1065
1066 assert_eq!(
1068 messages[0]["content"],
1069 serde_json::Value::String("part 1\npart 2".to_string())
1070 );
1071 }
1072
1073 #[test]
1076 fn test_may_be_fix_msg_content_mixed_messages() {
1077 let json_str = r#"{
1078 "model": "gpt-4o",
1079 "messages": [
1080 {
1081 "role": "system",
1082 "content": "You are a helpful assistant"
1083 },
1084 {
1085 "role": "user",
1086 "content": [
1087 {"type": "text", "text": "Hello"},
1088 {"type": "text", "text": "World"}
1089 ]
1090 },
1091 {
1092 "role": "assistant",
1093 "content": "Hi there!"
1094 },
1095 {
1096 "role": "user",
1097 "content": [
1098 {"type": "text", "text": "Another"},
1099 {"type": "text", "text": "multi-part"},
1100 {"type": "text", "text": "message"}
1101 ]
1102 }
1103 ]
1104 }"#;
1105
1106 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1107 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1108
1109 let messages =
1111 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1112
1113 assert_eq!(
1115 messages[0]["content"],
1116 serde_json::Value::String("You are a helpful assistant".to_string())
1117 );
1118
1119 assert_eq!(
1121 messages[1]["content"],
1122 serde_json::Value::String("Hello\nWorld".to_string())
1123 );
1124
1125 assert_eq!(
1127 messages[2]["content"],
1128 serde_json::Value::String("Hi there!".to_string())
1129 );
1130
1131 assert_eq!(
1133 messages[3]["content"],
1134 serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
1135 );
1136 }
1137
1138 #[test]
1140 fn test_may_be_fix_msg_content_empty_array() {
1141 let json_str = r#"{
1142 "model": "gpt-4o",
1143 "messages": [
1144 {
1145 "role": "user",
1146 "content": []
1147 }
1148 ]
1149 }"#;
1150
1151 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1152 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1153
1154 let messages =
1156 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1157
1158 assert!(messages[0]["content"].is_array());
1160 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1161 }
1162
1163 #[test]
1170 fn test_may_be_fix_msg_content_empty_array_with_placeholder_template() {
1171 let json_str = r#"{
1172 "model": "phi-3-vision",
1173 "messages": [
1174 {
1175 "role": "user",
1176 "content": []
1177 }
1178 ]
1179 }"#;
1180
1181 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1182 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1183
1184 let messages = serde_json::to_value(may_be_fix_msg_content(
1187 messages_raw,
1188 false,
1189 Some("<|image_{n}|>"),
1190 ))
1191 .unwrap();
1192
1193 assert!(
1194 messages[0]["content"].is_array(),
1195 "empty array should be preserved as `[]`, not flattened to `\"\"`"
1196 );
1197 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1198 }
1199
1200 #[test]
1202 fn test_may_be_fix_msg_content_single_text() {
1203 let json_str = r#"{
1204 "model": "gpt-4o",
1205 "messages": [
1206 {
1207 "role": "user",
1208 "content": "Simple text message"
1209 }
1210 ]
1211 }"#;
1212
1213 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1214 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1215
1216 let messages =
1218 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1219
1220 assert_eq!(
1222 messages[0]["content"],
1223 serde_json::Value::String("Simple text message".to_string())
1224 );
1225 }
1226
1227 #[test]
1230 fn test_may_be_fix_msg_content_mixed_types() {
1231 let json_str = r#"{
1232 "model": "gpt-4o",
1233 "messages": [
1234 {
1235 "role": "user",
1236 "content": [
1237 {"type": "text", "text": "Check this image:"},
1238 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
1239 {"type": "text", "text": "What do you see?"}
1240 ]
1241 }
1242 ]
1243 }"#;
1244
1245 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1246 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1247
1248 let messages =
1250 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1251
1252 assert!(messages[0]["content"].is_array());
1255 let content_array = messages[0]["content"].as_array().unwrap();
1256 assert_eq!(content_array.len(), 3);
1257 assert_eq!(content_array[0]["type"], "text");
1258 assert_eq!(content_array[1]["type"], "image");
1259 assert!(content_array[1].get("image_url").is_none());
1260 assert_eq!(content_array[2]["type"], "text");
1261 }
1262
1263 #[test]
1269 fn test_may_be_fix_msg_content_flattens_phi3_style() {
1270 let json_str = r#"{
1271 "model": "phi-3-vision",
1272 "messages": [
1273 {
1274 "role": "user",
1275 "content": [
1276 {"type": "text", "text": "First "},
1277 {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}},
1278 {"type": "text", "text": " then "},
1279 {"type": "image_url", "image_url": {"url": "https://example.com/b.jpg"}},
1280 {"type": "text", "text": "?"}
1281 ]
1282 }
1283 ]
1284 }"#;
1285 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1286 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1287
1288 let messages = serde_json::to_value(may_be_fix_msg_content(
1289 messages_raw,
1290 false,
1291 Some("<|image_{n}|>"),
1292 ))
1293 .unwrap();
1294
1295 let content = messages[0]["content"].as_str().expect("content flattened");
1296 assert_eq!(content, "First <|image_1|> then <|image_2|>?");
1297 }
1298
1299 #[test]
1301 fn test_may_be_fix_msg_content_flattens_llava_style() {
1302 let json_str = r#"{
1303 "model": "llava-1.5-7b-hf",
1304 "messages": [
1305 {
1306 "role": "user",
1307 "content": [
1308 {"type": "text", "text": "Describe: "},
1309 {"type": "image_url", "image_url": {"url": "https://example.com/x.jpg"}}
1310 ]
1311 }
1312 ]
1313 }"#;
1314 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1315 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1316
1317 let messages =
1318 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some("<image>")))
1319 .unwrap();
1320
1321 let content = messages[0]["content"].as_str().expect("content flattened");
1322 assert_eq!(content, "Describe: <image>");
1323 }
1324
1325 #[test]
1331 fn test_may_be_fix_msg_content_flattens_empty_placeholder() {
1332 let json_str = r#"{
1333 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1334 "messages": [
1335 {
1336 "role": "user",
1337 "content": [
1338 {"type": "text", "text": "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"},
1339 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1340 ]
1341 }
1342 ]
1343 }"#;
1344 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1345 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1346
1347 let messages =
1348 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some(""))).unwrap();
1349
1350 let content = messages[0]["content"].as_str().expect("content flattened");
1351 assert_eq!(
1352 content,
1353 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
1354 );
1355 }
1356
1357 #[test]
1364 fn test_render_nemotron_parse_passthrough() {
1365 use super::super::tokcfg::ChatTemplate;
1366 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1367
1368 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1369 "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}"
1370 }))
1371 .unwrap();
1372 let formatter =
1373 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1374
1375 for prompt in [
1376 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>",
1377 "</s><s><predict_bbox><predict_classes><output_markdown><predict_text_in_pic>",
1378 ] {
1379 let request: NvCreateChatCompletionRequest =
1380 serde_json::from_value(serde_json::json!({
1381 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1382 "messages": [{
1383 "role": "user",
1384 "content": [
1385 {"type": "text", "text": prompt},
1386 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1387 ]
1388 }]
1389 }))
1390 .unwrap();
1391
1392 let rendered = formatter.render(&request).unwrap();
1393 assert_eq!(
1394 rendered, prompt,
1395 "rendered prompt must be the control tokens only, with no JSON-serialized image array"
1396 );
1397 }
1398 }
1399
1400 #[test]
1403 fn test_may_be_fix_msg_content_non_text_only() {
1404 let json_str = r#"{
1405 "model": "gpt-4o",
1406 "messages": [
1407 {
1408 "role": "user",
1409 "content": [
1410 {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}},
1411 {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}
1412 ]
1413 }
1414 ]
1415 }"#;
1416
1417 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1418 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1419
1420 let messages =
1422 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1423
1424 assert!(messages[0]["content"].is_array());
1426 let content_array = messages[0]["content"].as_array().unwrap();
1427 assert_eq!(content_array.len(), 2);
1428 assert_eq!(content_array[0]["type"], "image");
1429 assert_eq!(content_array[1]["type"], "image");
1430 }
1431
1432 #[test]
1433 fn test_none_tools_safe_for_all_templates() {
1434 use super::tokcfg::ChatTemplate;
1435 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1436
1437 let length_template = r#"
1441{%- if tools is iterable and tools | length > 0 %}
1442Tools available: {{ tools | length }}
1443{%- else %}
1444No tools
1445{%- endif %}
1446"#;
1447
1448 let no_tool_template = r#"
1451{%- if tools is not none %}
1452TOOL MODE
1453{%- else %}
1454NORMAL MODE
1455{%- endif %}
1456"#;
1457
1458 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1459 "chat_template": [
1460 {"safe_length": length_template},
1461 {"no_tool": no_tool_template}
1462 ]
1463 }))
1464 .unwrap();
1465
1466 let formatter =
1467 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1468
1469 let ctx = context! { tools => Option::<Value>::None };
1470
1471 let result1 = formatter
1472 .env
1473 .get_template("safe_length")
1474 .unwrap()
1475 .render(&ctx);
1476 println!("Safe length template with no tools => None: {:?}", result1);
1477 assert!(
1478 result1.is_ok(),
1479 "Jinja template with and conditional and length filter should handle None: {:?}",
1480 result1
1481 );
1482 assert!(
1483 result1.unwrap().contains("No tools"),
1484 "Should show 'No tools'"
1485 );
1486
1487 let result2 = formatter.env.get_template("no_tool").unwrap().render(&ctx);
1488 println!("Default template with no tools => None: {:?}", result2);
1489 assert!(
1490 result2.is_ok(),
1491 "Jinja template with if tools is not none conditional should handle None: {:?}",
1492 result2
1493 );
1494 assert!(result2.unwrap().contains("NORMAL MODE"));
1495 }
1496
1497 #[test]
1499 fn test_may_be_fix_msg_content_multiple_content_types() {
1500 let json_str = r#"{
1502 "model": "gpt-4o",
1503 "messages": [
1504 {
1505 "role": "user",
1506 "content": [
1507 {"type": "text", "text": "Listen to this:"},
1508 {"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}},
1509 {"type": "text", "text": "And look at:"},
1510 {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
1511 {"type": "text", "text": "What do you think?"}
1512 ]
1513 }
1514 ]
1515 }"#;
1516
1517 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1518 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1519 let messages =
1520 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1521
1522 assert!(messages[0]["content"].is_array());
1524 let content_array = messages[0]["content"].as_array().unwrap();
1525 assert_eq!(content_array.len(), 5);
1526 assert_eq!(content_array[0]["type"], "text");
1527 assert_eq!(content_array[1]["type"], "audio");
1528 assert_eq!(content_array[2]["type"], "text");
1529 assert_eq!(content_array[3]["type"], "image");
1530 assert_eq!(content_array[4]["type"], "text");
1531
1532 let json_str = r#"{
1534 "model": "gpt-4o",
1535 "messages": [
1536 {
1537 "role": "user",
1538 "content": [
1539 {"type": "text", "text": "Check this:"},
1540 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1541 {"type": "text", "text": "Interesting?"}
1542 ]
1543 }
1544 ]
1545 }"#;
1546
1547 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1548 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1549 let messages =
1550 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1551
1552 assert!(messages[0]["content"].is_array());
1554 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1555 }
1556
1557 #[test]
1558 fn test_normalize_tool_arguments_tojson() {
1559 let tmpl = r#"{{ messages[0].tool_calls[0].function.arguments | tojson }}"#;
1560
1561 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1563 "role": "assistant",
1564 "tool_calls": [{
1565 "type": "function",
1566 "function": {
1567 "name": "get_current_weather",
1568 "arguments": "{\"format\":\"celsius\",\"location\":\"San Francisco, CA\"}"
1569 }
1570 }]
1571 })]);
1572
1573 normalize_tool_calls_arguments_in_messages(&mut messages);
1574
1575 let mut env = Environment::new();
1576 env.add_filter("tojson", super::super::tokcfg::tojson);
1577 env.add_template("t", tmpl).unwrap();
1578 let out = env
1579 .get_template("t")
1580 .unwrap()
1581 .render(context! { messages => messages.as_array().unwrap() })
1582 .unwrap();
1583
1584 assert_eq!(
1587 out,
1588 r#"{"format": "celsius", "location": "San Francisco, CA"}"#
1589 );
1590 }
1591
1592 #[test]
1593 fn test_normalize_tool_arguments_items_loop() {
1594 let tmpl = r#"{% for k, v in messages[0].tool_calls[0].function.arguments|items %}{{k}}={{v}};{% endfor %}"#;
1595
1596 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1597 "role": "assistant",
1598 "tool_calls": [{
1599 "type": "function",
1600 "function": {
1601 "name": "f",
1602 "arguments": "{\"a\":1,\"b\":\"x\"}"
1603 }
1604 }]
1605 })]);
1606
1607 normalize_tool_calls_arguments_in_messages(&mut messages);
1608
1609 let mut env = Environment::new();
1610 env.add_template("t", tmpl).unwrap();
1611 let out = env
1612 .get_template("t")
1613 .unwrap()
1614 .render(context! { messages => messages.as_array().unwrap() })
1615 .unwrap();
1616
1617 assert!(out == "a=1;b=x;" || out == "b=x;a=1;");
1618 }
1619
1620 #[test]
1621 fn test_normalize_tool_arguments_legacy_function_call() {
1622 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1624 "role": "assistant",
1625 "function_call": {
1626 "name": "get_weather",
1627 "arguments": "{\"location\":\"NYC\"}"
1628 }
1629 })]);
1630
1631 normalize_function_call_arguments_in_messages(&mut messages);
1632
1633 assert_eq!(
1634 messages[0]["function_call"]["arguments"],
1635 serde_json::json!({"location": "NYC"})
1636 );
1637 }
1638
1639 #[test]
1640 fn test_normalize_tool_arguments_malformed_json_passthrough() {
1641 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1643 "role": "assistant",
1644 "tool_calls": [{
1645 "type": "function",
1646 "function": {
1647 "name": "f",
1648 "arguments": "not valid json at all"
1649 }
1650 }]
1651 })]);
1652
1653 normalize_tool_calls_arguments_in_messages(&mut messages);
1654
1655 assert_eq!(
1656 messages[0]["tool_calls"][0]["function"]["arguments"],
1657 serde_json::Value::String("not valid json at all".to_string())
1658 );
1659 }
1660
1661 #[test]
1662 fn test_normalize_tool_arguments_with_multimodal_content() {
1663 let json_str = r#"{
1664 "model": "gpt-4o",
1665 "messages": [
1666 {
1667 "role": "user",
1668 "content": [
1669 {"type": "text", "text": "Check this:"},
1670 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1671 {"type": "text", "text": "Interesting?"}
1672 ]
1673 },
1674 {
1675 "role": "assistant",
1676 "tool_calls": [{
1677 "id": "call_123",
1678 "type": "function",
1679 "function": {
1680 "name": "analyze_video",
1681 "arguments": "{\"url\":\"https://example.com/vid.mp4\",\"format\":\"mp4\"}"
1682 }
1683 }]
1684 }
1685 ]
1686 }"#;
1687
1688 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1689 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1690
1691 let mut messages =
1693 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1694
1695 normalize_tool_calls_arguments_in_messages(&mut messages);
1696
1697 assert!(messages[0]["content"].is_array());
1699 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1700
1701 assert!(messages[1]["tool_calls"][0]["function"]["arguments"].is_object());
1703 assert_eq!(
1704 messages[1]["tool_calls"][0]["function"]["arguments"]["url"],
1705 "https://example.com/vid.mp4"
1706 );
1707 }
1708
1709 #[test]
1711 fn test_may_be_fix_msg_content_string_to_array() {
1712 let json_str = r#"{
1713 "model": "gpt-4o",
1714 "messages": [
1715 {
1716 "role": "user",
1717 "content": "Hello, how are you?"
1718 }
1719 ]
1720 }"#;
1721
1722 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1723 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1724
1725 let messages =
1727 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1728
1729 assert!(messages[0]["content"].is_array());
1731 let content_array = messages[0]["content"].as_array().unwrap();
1732 assert_eq!(content_array.len(), 1);
1733 assert_eq!(content_array[0]["type"], "text");
1734 assert_eq!(content_array[0]["text"], "Hello, how are you?");
1735 }
1736
1737 #[test]
1739 fn test_may_be_fix_msg_content_array_preserved_with_multimodal() {
1740 let json_str = r#"{
1741 "model": "gpt-4o",
1742 "messages": [
1743 {
1744 "role": "user",
1745 "content": [
1746 {"type": "text", "text": "part 1"},
1747 {"type": "text", "text": "part 2"}
1748 ]
1749 }
1750 ]
1751 }"#;
1752
1753 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1754 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1755
1756 let messages =
1758 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1759
1760 assert!(messages[0]["content"].is_array());
1762 let content_array = messages[0]["content"].as_array().unwrap();
1763 assert_eq!(content_array.len(), 2);
1764 assert_eq!(content_array[0]["text"], "part 1");
1765 assert_eq!(content_array[1]["text"], "part 2");
1766 }
1767
1768 fn user() -> Msg {
1769 Msg::User(Default::default())
1770 }
1771 fn tool() -> Msg {
1772 Msg::Tool(Default::default())
1773 }
1774
1775 fn dummy_state(messages: Vec<Msg>) -> NvCreateChatCompletionRequest {
1776 let json = serde_json::json!({
1777 "model": "test-model",
1778 "messages": messages
1779 });
1780 serde_json::from_value(json).unwrap()
1781 }
1782
1783 #[test]
1784 fn add_after_user() {
1785 let s = dummy_state(vec![user()]);
1786 assert!(s.should_add_generation_prompt());
1787 }
1788
1789 #[test]
1790 fn add_after_tool() {
1791 let s = dummy_state(vec![tool()]);
1792 assert!(s.should_add_generation_prompt());
1793 }
1794
1795 #[test]
1796 fn add_when_empty() {
1797 let s = dummy_state(vec![]);
1798 assert!(s.should_add_generation_prompt());
1799 }
1800
1801 fn tool_aware_formatter(
1803 exclude_tools_when_tool_choice_none: bool,
1804 ) -> HfTokenizerConfigJsonFormatter {
1805 let template = r#"
1806{%- if tools is iterable and tools | length > 0 %}
1807TOOL_MODE tools={{ tools | length }}
1808{%- else %}
1809NORMAL_MODE
1810{%- endif %}
1811{{ messages[0].content }}"#;
1812
1813 let chat_template: super::tokcfg::ChatTemplate =
1814 serde_json::from_value(serde_json::json!({ "chat_template": template })).unwrap();
1815
1816 HfTokenizerConfigJsonFormatter::with_options(
1817 chat_template,
1818 ContextMixins::new(&[]),
1819 exclude_tools_when_tool_choice_none,
1820 )
1821 .unwrap()
1822 }
1823
1824 fn gemma4_tool_template_for_tests() -> &'static str {
1825 r#"
1826{{ bos_token }}
1827{%- set loop_messages = messages -%}
1828{%- set ns_turn = namespace(last_user_idx=-1) -%}
1829{%- for i in range(loop_messages | length) -%}
1830 {%- if loop_messages[i]['role'] == 'user' -%}
1831 {%- set ns_turn.last_user_idx = i -%}
1832 {%- endif -%}
1833{%- endfor -%}
1834{%- for message in loop_messages -%}
1835 {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
1836 {{- '<|turn>' + role + '\n' }}
1837
1838 {%- if message.get('reasoning') and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
1839 {{- '<|channel>thought\n' + message['reasoning'] + '\n<channel|>'}}
1840 {%- endif -%}
1841
1842 {%- if message['tool_calls'] -%}
1843 {%- for tool_call in message['tool_calls'] -%}
1844 {%- set function = tool_call['function'] -%}
1845 {{- '<|tool_call>call:' + function['name'] + '{' -}}
1846 {%- if function['arguments'] is mapping -%}
1847 {%- set ns_args = namespace(found_first=false) -%}
1848 {%- for key, value in function['arguments'] | dictsort -%}
1849 {%- if ns_args.found_first %},{% endif -%}
1850 {%- set ns_args.found_first = true -%}
1851 {{- key -}}:{{- value -}}
1852 {%- endfor -%}
1853 {%- elif function['arguments'] is string -%}
1854 {{- function['arguments'] -}}
1855 {%- endif -%}
1856 {{- '}<tool_call|>' -}}
1857 {%- endfor -%}
1858 {%- endif -%}
1859
1860 {%- if message['content'] is string -%}
1861 {{- message['content'] -}}
1862 {%- endif -%}
1863 {{- '<turn|>\n' -}}
1864{%- endfor -%}
1865"#
1866 }
1867
1868 fn make_gemma4_tool_formatter_for_tests() -> HfTokenizerConfigJsonFormatter {
1869 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1870 "chat_template": gemma4_tool_template_for_tests()
1871 }))
1872 .unwrap();
1873 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
1874 }
1875
1876 fn request_with_tool_choice(tool_choice: &str) -> NvCreateChatCompletionRequest {
1878 serde_json::from_value(serde_json::json!({
1879 "model": "test",
1880 "messages": [{"role": "user", "content": "hello"}],
1881 "tools": [{
1882 "type": "function",
1883 "function": {
1884 "name": "get_weather",
1885 "description": "Get weather",
1886 "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}
1887 }
1888 }],
1889 "tool_choice": tool_choice
1890 }))
1891 .unwrap()
1892 }
1893
1894 #[test]
1895 fn test_exclude_tools_strips_when_tool_choice_none() {
1896 let formatter = tool_aware_formatter(true);
1897 let request = request_with_tool_choice("none");
1898 let result = formatter.render(&request).unwrap();
1899 assert!(
1900 result.contains("NORMAL_MODE"),
1901 "With exclude_tools=true and tool_choice=none, tools should be stripped. Got: {}",
1902 result
1903 );
1904 }
1905
1906 #[test]
1907 fn test_exclude_tools_keeps_when_tool_choice_auto() {
1908 let formatter = tool_aware_formatter(true);
1909 let request = request_with_tool_choice("auto");
1910 let result = formatter.render(&request).unwrap();
1911 assert!(
1912 result.contains("TOOL_MODE"),
1913 "With tool_choice=auto, tools should be included. Got: {}",
1914 result
1915 );
1916 }
1917
1918 #[test]
1919 fn test_no_exclude_tools_keeps_when_tool_choice_none() {
1920 let formatter = tool_aware_formatter(false);
1921 let request = request_with_tool_choice("none");
1922 let result = formatter.render(&request).unwrap();
1923 assert!(
1924 result.contains("TOOL_MODE"),
1925 "With exclude_tools=false and tool_choice=none, tools should NOT be stripped. Got: {}",
1926 result
1927 );
1928 }
1929
1930 #[test]
1931 fn test_inject_reasoning_content_segments_with_tool_calls() {
1932 let mut messages = serde_json::json!([
1934 {
1935 "role": "user",
1936 "content": "What is sqrt(144) and sqrt(256)?"
1937 },
1938 {
1939 "role": "assistant",
1940 "content": "Let me calculate those.",
1941 "reasoning_content": ["I need to compute sqrt(144)", "Now sqrt(256)", ""],
1942 "tool_calls": [
1943 {
1944 "id": "call_0",
1945 "type": "function",
1946 "function": {
1947 "name": "calculator",
1948 "arguments": "{\"expr\": \"sqrt(144)\"}"
1949 }
1950 },
1951 {
1952 "id": "call_1",
1953 "type": "function",
1954 "function": {
1955 "name": "calculator",
1956 "arguments": "{\"expr\": \"sqrt(256)\"}"
1957 }
1958 }
1959 ]
1960 }
1961 ]);
1962
1963 inject_reasoning_content_into_messages(&mut messages);
1964
1965 let assistant = &messages[1];
1966
1967 assert!(
1969 assistant.get("reasoning_content").is_none(),
1970 "reasoning_content should be removed after injection"
1971 );
1972
1973 let content = assistant["content"].as_str().unwrap();
1975 assert!(
1976 content.starts_with("<think>I need to compute sqrt(144)</think>"),
1977 "content should start with first reasoning segment, got: {}",
1978 content
1979 );
1980 assert!(
1981 content.contains("<think>Now sqrt(256)</think>"),
1982 "content should contain second reasoning segment"
1983 );
1984 assert!(
1986 !content.contains("<think></think>"),
1987 "empty segments should be skipped"
1988 );
1989 assert!(
1991 content.ends_with("Let me calculate those."),
1992 "original content should be at the end, got: {}",
1993 content
1994 );
1995
1996 assert!(assistant.get("tool_calls").is_some());
1998 assert_eq!(assistant["tool_calls"].as_array().unwrap().len(), 2);
1999 }
2000
2001 #[test]
2002 fn test_gemma4_template_renders_reasoning_content_segments_around_tool_calls() {
2003 let formatter = make_gemma4_tool_formatter_for_tests();
2004 assert!(
2005 formatter.tool_use_template_handles_reasoning,
2006 "Gemma4 template adaptation should make reasoning_content native"
2007 );
2008
2009 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2010 "model": "gemma4-test",
2011 "messages": [
2012 {"role": "user", "content": "inspect two things"},
2013 {
2014 "role": "assistant",
2015 "content": null,
2016 "reasoning_content": [
2017 "Think before the first call.",
2018 "Think before the second call.",
2019 "Think after both calls."
2020 ],
2021 "tool_calls": [
2022 {
2023 "id": "call_0",
2024 "type": "function",
2025 "function": {
2026 "name": "first_tool",
2027 "arguments": "{\"path\":\".\"}"
2028 }
2029 },
2030 {
2031 "id": "call_1",
2032 "type": "function",
2033 "function": {
2034 "name": "second_tool",
2035 "arguments": "{\"path\":\"/tmp\"}"
2036 }
2037 }
2038 ]
2039 }
2040 ]
2041 }))
2042 .unwrap();
2043
2044 let rendered = formatter.render(&request).unwrap();
2045
2046 let expected = concat!(
2047 "<|channel>thought\nThink before the first call.\n<channel|>",
2048 "<|tool_call>call:first_tool{path:.}<tool_call|>",
2049 "<|channel>thought\nThink before the second call.\n<channel|>",
2050 "<|tool_call>call:second_tool{path:/tmp}<tool_call|>",
2051 "<|channel>thought\nThink after both calls.\n<channel|>"
2052 );
2053 assert!(
2054 rendered.contains(expected),
2055 "Gemma4 reasoning segments should stay adjacent to their tool calls, got: {rendered}"
2056 );
2057 assert!(!rendered.contains("<think>"));
2058 assert!(!rendered.contains("reasoning_content"));
2059 }
2060
2061 #[test]
2062 fn test_gemma4_template_renders_reasoning_content_without_tool_calls() {
2063 let formatter = make_gemma4_tool_formatter_for_tests();
2064 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2065 "model": "gemma4-test",
2066 "messages": [
2067 {"role": "user", "content": "answer directly"},
2068 {
2069 "role": "assistant",
2070 "content": "Direct answer.",
2071 "reasoning_content": "Private thought."
2072 }
2073 ]
2074 }))
2075 .unwrap();
2076
2077 let rendered = formatter.render(&request).unwrap();
2078
2079 assert!(
2080 rendered.contains("<|channel>thought\nPrivate thought.\n<channel|>Direct answer."),
2081 "Gemma4 reasoning_content should render in the thought channel, got: {rendered}"
2082 );
2083 assert!(!rendered.contains("<think>"));
2084 assert!(!rendered.contains("reasoning_content"));
2085 }
2086
2087 #[test]
2093 fn test_reasoning_flag_is_per_template_not_global() {
2094 const PLAIN_DEFAULT: &str = "{{ bos_token }}{%- for message in messages -%}\
2097 {{ message['role'] }}: {{ message['content'] }}\n{%- endfor -%}";
2098
2099 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2100 "chat_template": [
2101 {"default": PLAIN_DEFAULT},
2102 {"tool_use": gemma4_tool_template_for_tests()},
2103 ]
2104 }))
2105 .unwrap();
2106 let formatter =
2107 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2108
2109 assert!(
2113 formatter.tool_use_template_handles_reasoning,
2114 "adapted gemma4 tool_use template should handle reasoning natively"
2115 );
2116 assert!(
2117 !formatter.default_template_handles_reasoning,
2118 "plain default template does not reference reasoning_content"
2119 );
2120
2121 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2124 "model": "gemma4-test",
2125 "messages": [
2126 {"role": "user", "content": "answer directly"},
2127 {
2128 "role": "assistant",
2129 "content": "Direct answer.",
2130 "reasoning_content": "Private thought."
2131 }
2132 ]
2133 }))
2134 .unwrap();
2135
2136 let rendered = formatter.render(&request).unwrap();
2137 assert!(
2138 rendered.contains("<think>Private thought.</think>Direct answer."),
2139 "reasoning must be injected on the no-tool default path, got: {rendered}"
2140 );
2141 }
2142
2143 #[test]
2144 fn test_inject_reasoning_content_text_variant() {
2145 let mut messages = serde_json::json!([
2146 {
2147 "role": "assistant",
2148 "content": "The answer is 42.",
2149 "reasoning_content": "Let me think about this carefully."
2150 }
2151 ]);
2152
2153 inject_reasoning_content_into_messages(&mut messages);
2154
2155 let assistant = &messages[0];
2156 assert!(assistant.get("reasoning_content").is_none());
2157 let content = assistant["content"].as_str().unwrap();
2158 assert_eq!(
2159 content,
2160 "<think>Let me think about this carefully.</think>The answer is 42."
2161 );
2162 }
2163
2164 #[test]
2165 fn test_inject_reasoning_content_null_content() {
2166 let mut messages = serde_json::json!([
2168 {
2169 "role": "assistant",
2170 "content": null,
2171 "reasoning_content": "Thinking...",
2172 "tool_calls": [{"id": "call_0", "type": "function", "function": {"name": "f", "arguments": "{}"}}]
2173 }
2174 ]);
2175
2176 inject_reasoning_content_into_messages(&mut messages);
2177
2178 let content = messages[0]["content"].as_str().unwrap();
2179 assert_eq!(content, "<think>Thinking...</think>");
2180 assert!(messages[0].get("reasoning_content").is_none());
2181 }
2182
2183 #[test]
2184 fn test_inject_reasoning_content_skips_non_assistant() {
2185 let mut messages = serde_json::json!([
2186 {
2187 "role": "user",
2188 "content": "hello",
2189 "reasoning_content": "should not be touched"
2190 }
2191 ]);
2192
2193 inject_reasoning_content_into_messages(&mut messages);
2194
2195 assert!(messages[0].get("reasoning_content").is_some());
2197 }
2198
2199 fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
2201 use super::tokcfg::ChatTemplate;
2202 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2203
2204 let template = r#"{%- for message in messages %}{{ message.role }}: {{ message.content }}
2207{%- endfor %}
2208{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2209
2210 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2211 "chat_template": template
2212 }))
2213 .unwrap();
2214
2215 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2216 }
2217
2218 #[test]
2221 fn test_reasoning_content_text_roundtrip_render() {
2222 use super::OAIPromptFormatter;
2223 let formatter = make_test_formatter();
2224
2225 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2226 "model": "test-model",
2227 "messages": [
2228 {"role": "user", "content": "What is sqrt(144)?"},
2229 {
2230 "role": "assistant",
2231 "content": "The answer is 12.",
2232 "reasoning_content": "I need to compute the square root of 144."
2233 },
2234 {"role": "user", "content": "Are you sure?"}
2235 ]
2236 }))
2237 .unwrap();
2238
2239 let rendered = formatter.render(&request).unwrap();
2240
2241 assert!(
2242 rendered.contains("<think>I need to compute the square root of 144.</think>"),
2243 "reasoning_content must appear as <think> block, got: {}",
2244 rendered
2245 );
2246 assert!(
2247 rendered.contains("The answer is 12."),
2248 "original content must be preserved"
2249 );
2250 assert!(
2251 !rendered.contains("reasoning_content"),
2252 "raw reasoning_content field should not leak into prompt"
2253 );
2254 }
2255
2256 #[test]
2260 fn test_reasoning_content_agentic_tool_call_roundtrip_render() {
2261 use super::OAIPromptFormatter;
2262 let formatter = make_test_formatter();
2263
2264 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2265 "model": "test-model",
2266 "messages": [
2267 {"role": "user", "content": "What is sqrt(144) + sqrt(256)?"},
2268 {
2269 "role": "assistant",
2270 "content": null,
2271 "reasoning_content": "I need to compute both square roots. Let me start with sqrt(144).",
2272 "tool_calls": [{
2273 "id": "call_0",
2274 "type": "function",
2275 "function": {
2276 "name": "calculator",
2277 "arguments": "{\"expr\": \"sqrt(144)\"}"
2278 }
2279 }]
2280 },
2281 {
2282 "role": "tool",
2283 "tool_call_id": "call_0",
2284 "content": "12"
2285 },
2286 {
2287 "role": "assistant",
2288 "content": "sqrt(144) = 12 and sqrt(256) = 16, so the answer is 28.",
2289 "reasoning_content": "Got 12 for sqrt(144). Now sqrt(256) = 16. Sum is 28."
2290 },
2291 {"role": "user", "content": "Thanks!"}
2292 ]
2293 }))
2294 .unwrap();
2295
2296 let rendered = formatter.render(&request).unwrap();
2297
2298 assert!(
2300 rendered.contains("<think>I need to compute both square roots"),
2301 "first turn reasoning must be in prompt, got: {}",
2302 rendered
2303 );
2304 assert!(
2306 rendered.contains("<think>Got 12 for sqrt(144)"),
2307 "second turn reasoning must be in prompt"
2308 );
2309 assert!(
2310 rendered.contains("the answer is 28"),
2311 "final answer content must be preserved"
2312 );
2313 assert!(
2315 !rendered.contains("reasoning_content"),
2316 "raw reasoning_content field should not leak into prompt"
2317 );
2318 }
2319
2320 #[test]
2322 fn test_reasoning_injected_when_template_ignores_it() {
2323 use super::OAIPromptFormatter;
2324 let formatter = make_test_formatter();
2325
2326 assert!(!formatter.default_template_handles_reasoning);
2328 assert!(!formatter.tool_use_template_handles_reasoning);
2329
2330 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2331 "model": "test-model",
2332 "messages": [
2333 {"role": "user", "content": "Hello"},
2334 {
2335 "role": "assistant",
2336 "content": "Hi.",
2337 "reasoning_content": "The user said hello."
2338 },
2339 {"role": "user", "content": "Bye"}
2340 ]
2341 }))
2342 .unwrap();
2343
2344 let rendered = formatter.render(&request).unwrap();
2345 assert!(
2346 rendered.contains("<think>The user said hello.</think>"),
2347 "injection must happen when template ignores reasoning_content, got: {}",
2348 rendered
2349 );
2350 }
2351
2352 #[test]
2354 fn test_reasoning_not_injected_when_template_handles_it() {
2355 use super::tokcfg::ChatTemplate;
2356 use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
2357
2358 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>
2360{%- endif %}{{ message.role }}: {{ message.content }}
2361{%- endfor %}
2362{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2363
2364 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2365 "chat_template": template
2366 }))
2367 .unwrap();
2368
2369 let formatter =
2370 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2371
2372 assert!(formatter.default_template_handles_reasoning);
2374 assert!(formatter.tool_use_template_handles_reasoning);
2375
2376 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2377 "model": "test-model",
2378 "messages": [
2379 {"role": "user", "content": "Hello"},
2380 {
2381 "role": "assistant",
2382 "content": "Hi.",
2383 "reasoning_content": "The user said hello."
2384 },
2385 {"role": "user", "content": "Bye"}
2386 ]
2387 }))
2388 .unwrap();
2389
2390 let rendered = formatter.render(&request).unwrap();
2391
2392 assert!(
2394 rendered.contains("<think>The user said hello.</think>"),
2395 "template must render reasoning_content natively, got: {}",
2396 rendered
2397 );
2398 let think_count = rendered.matches("<think>").count();
2400 assert_eq!(
2401 think_count, 1,
2402 "must have exactly one <think> block (from template), got {} in: {}",
2403 think_count, rendered
2404 );
2405 }
2406
2407 const QWEN3_THINKING_TEMPLATE: &str = r##"{%- if tools %}
2411 {{- '<|im_start|>system\n' }}
2412 {%- if messages[0].role == 'system' %}
2413 {{- messages[0].content + '\n\n' }}
2414 {%- endif %}
2415 {{- "# 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>" }}
2416 {%- for tool in tools %}
2417 {{- "\n" }}
2418 {{- tool | tojson }}
2419 {%- endfor %}
2420 {{- "\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" }}
2421{%- else %}
2422 {%- if messages[0].role == 'system' %}
2423 {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
2424 {%- endif %}
2425{%- endif %}
2426{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
2427{%- for message in messages[::-1] %}
2428 {%- set index = (messages|length - 1) - loop.index0 %}
2429 {%- 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>')) %}
2430 {%- set ns.multi_step_tool = false %}
2431 {%- set ns.last_query_index = index %}
2432 {%- endif %}
2433{%- endfor %}
2434{%- for message in messages %}
2435 {%- if message.content is string %}
2436 {%- set content = message.content %}
2437 {%- else %}
2438 {%- set content = '' %}
2439 {%- endif %}
2440 {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
2441 {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
2442 {%- elif message.role == "assistant" %}
2443 {%- set reasoning_content = '' %}
2444 {%- if message.reasoning_content is string %}
2445 {%- set reasoning_content = message.reasoning_content %}
2446 {%- else %}
2447 {%- if '</think>' in content %}
2448 {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
2449 {%- set content = content.split('</think>')[-1].lstrip('\n') %}
2450 {%- endif %}
2451 {%- endif %}
2452 {%- if loop.index0 > ns.last_query_index %}
2453 {%- if loop.last or (not loop.last and reasoning_content) %}
2454 {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
2455 {%- else %}
2456 {{- '<|im_start|>' + message.role + '\n' + content }}
2457 {%- endif %}
2458 {%- else %}
2459 {{- '<|im_start|>' + message.role + '\n' + content }}
2460 {%- endif %}
2461 {%- if message.tool_calls %}
2462 {%- for tool_call in message.tool_calls %}
2463 {%- if (loop.first and content) or (not loop.first) %}
2464 {{- '\n' }}
2465 {%- endif %}
2466 {%- if tool_call.function %}
2467 {%- set tool_call = tool_call.function %}
2468 {%- endif %}
2469 {{- '<tool_call>\n{"name": "' }}
2470 {{- tool_call.name }}
2471 {{- '", "arguments": ' }}
2472 {%- if tool_call.arguments is string %}
2473 {{- tool_call.arguments }}
2474 {%- else %}
2475 {{- tool_call.arguments | tojson }}
2476 {%- endif %}
2477 {{- '}\n</tool_call>' }}
2478 {%- endfor %}
2479 {%- endif %}
2480 {{- '<|im_end|>\n' }}
2481 {%- elif message.role == "tool" %}
2482 {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
2483 {{- '<|im_start|>user' }}
2484 {%- endif %}
2485 {{- '\n<tool_response>\n' }}
2486 {{- content }}
2487 {{- '\n</tool_response>' }}
2488 {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
2489 {{- '<|im_end|>\n' }}
2490 {%- endif %}
2491 {%- endif %}
2492{%- endfor %}
2493{%- if add_generation_prompt %}
2494 {{- '<|im_start|>assistant\n<think>\n' }}
2495{%- endif %}"##;
2496
2497 fn qwen3_thinking_formatter() -> HfTokenizerConfigJsonFormatter {
2498 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2499 "chat_template": QWEN3_THINKING_TEMPLATE,
2500 }))
2501 .unwrap();
2502 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2503 }
2504
2505 #[test]
2506 fn test_qwen3_thinking_template_flags_detected() {
2507 let formatter = qwen3_thinking_formatter();
2508 assert!(
2509 formatter.tool_use_template_handles_reasoning,
2510 "template references reasoning_content directly"
2511 );
2512 assert!(
2515 formatter.default_template_handles_tool_calls_arguments_string,
2516 "default template branches on `arguments is string`"
2517 );
2518 assert!(
2519 formatter.tool_use_template_handles_tool_calls_arguments_string,
2520 "tool_use template branches on `arguments is string`"
2521 );
2522 }
2523
2524 #[test]
2534 fn test_qwen3_thinking_append_only_across_tool_use_turn() {
2535 let formatter = qwen3_thinking_formatter();
2536
2537 let tools = serde_json::json!([{
2538 "type": "function",
2539 "function": {
2540 "name": "get_weather",
2541 "description": "Get the current weather for a location",
2542 "parameters": {
2543 "type": "object",
2544 "properties": {
2545 "location": {"type": "string"},
2546 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
2547 },
2548 "required": ["location"]
2549 }
2550 }
2551 }]);
2552
2553 let turn1_request: NvCreateChatCompletionRequest =
2555 serde_json::from_value(serde_json::json!({
2556 "model": "qwen3-thinking",
2557 "messages": [
2558 {"role": "system", "content": "You are a helpful assistant."},
2559 {"role": "user", "content": "What's the weather in San Francisco?"},
2560 ],
2561 "tools": tools,
2562 }))
2563 .unwrap();
2564 let p1 = formatter.render(&turn1_request).unwrap();
2565
2566 let model_emitted = "I'll call get_weather for SF.\n\
2570 </think>\n\n\
2571 <tool_call>\n\
2572 {\"name\": \"get_weather\", \"arguments\": {\"location\": \"San Francisco\", \"unit\": \"celsius\"}}\n\
2573 </tool_call><|im_end|>\n";
2574 let wire_after_t1 = format!("{p1}{model_emitted}");
2575
2576 let turn2_request: NvCreateChatCompletionRequest =
2579 serde_json::from_value(serde_json::json!({
2580 "model": "qwen3-thinking",
2581 "messages": [
2582 {"role": "system", "content": "You are a helpful assistant."},
2583 {"role": "user", "content": "What's the weather in San Francisco?"},
2584 {
2585 "role": "assistant",
2586 "content": "",
2587 "reasoning_content": "I'll call get_weather for SF.",
2588 "tool_calls": [{
2589 "id": "call_sf",
2590 "type": "function",
2591 "function": {
2592 "name": "get_weather",
2593 "arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
2594 }
2595 }]
2596 },
2597 {
2598 "role": "tool",
2599 "tool_call_id": "call_sf",
2600 "content": "{\"temp\": 18, \"conditions\": \"Foggy\"}"
2601 }
2602 ],
2603 "tools": tools,
2604 }))
2605 .unwrap();
2606 let p2 = formatter.render(&turn2_request).unwrap();
2607
2608 if !p2.starts_with(&wire_after_t1) {
2609 let div = wire_after_t1
2611 .as_bytes()
2612 .iter()
2613 .zip(p2.as_bytes())
2614 .position(|(a, b)| a != b)
2615 .unwrap_or_else(|| wire_after_t1.len().min(p2.len()));
2616 let lo = div.saturating_sub(40);
2617 panic!(
2618 "turn-2 prompt is NOT a prefix-extension of [turn-1 + model bytes]\n \
2619 diverges at byte {div}\n \
2620 wire ends: ...{}|{}\n \
2621 t2 has: ...{}|{}",
2622 String::from_utf8_lossy(&wire_after_t1.as_bytes()[lo..div]),
2623 String::from_utf8_lossy(
2624 &wire_after_t1.as_bytes()[div..(div + 60).min(wire_after_t1.len())]
2625 ),
2626 String::from_utf8_lossy(&p2.as_bytes()[lo..div]),
2627 String::from_utf8_lossy(&p2.as_bytes()[div..(div + 60).min(p2.len())]),
2628 );
2629 }
2630
2631 let suffix = &p2[wire_after_t1.len()..];
2634 assert!(
2635 suffix.contains("<tool_response>"),
2636 "appended bytes must include the tool response, got: {suffix}"
2637 );
2638 assert!(
2639 suffix.ends_with("<|im_start|>assistant\n<think>\n"),
2640 "appended bytes must end with the next generation prompt, got: {suffix}"
2641 );
2642 }
2643}