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 should_add_generation_prompt(&self) -> bool {
418 true
420 }
421
422 fn extract_text(&self) -> Option<TextInput> {
423 Some(TextInput::Single(String::new()))
424 }
425
426 fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
427 self.mm_processor_kwargs.as_ref()
428 }
429}
430
431impl OAIPromptFormatter for HfTokenizerConfigJsonFormatter {
432 fn supports_add_generation_prompt(&self) -> bool {
433 self.supports_add_generation_prompt
434 }
435
436 fn image_placeholder_template(&self) -> Option<&'static str> {
437 self.image_placeholder_template
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) = if has_tools {
482 (
483 "tool_use",
484 self.tool_use_template_handles_tool_calls_arguments_string,
485 )
486 } else {
487 (
488 "default",
489 self.default_template_handles_tool_calls_arguments_string,
490 )
491 };
492
493 if !template_handles_tool_calls_args_string {
500 normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
501 }
502 normalize_function_call_arguments_in_messages(&mut messages_for_template);
506
507 if !self.template_handles_reasoning {
512 inject_reasoning_content_into_messages(&mut messages_for_template);
513 }
514
515 let ctx = context! {
516 messages => messages_for_template,
517 tools => tools,
518 bos_token => self.config.bos_tok(),
519 eos_token => self.config.eos_tok(),
520 unk_token => self.config.unk_tok(),
521 add_generation_prompt => add_generation_prompt,
522 ..mixins
523 };
524
525 let ctx = if let Some(args) = req.chat_template_args() {
527 let extra = Value::from_serialize(args);
528 context! { ..ctx, ..extra }
529 } else {
530 ctx
531 };
532
533 let tmpl: minijinja::Template<'_, '_> = self.env.get_template(template_name)?;
534 Ok(tmpl.render(&ctx)?)
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use dynamo_protocols::types::ChatCompletionRequestMessage as Msg;
542 use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
545 use minijinja::{Environment, context};
546
547 #[test]
558 #[ignore]
559 fn dump_gptoss_tool_prompt() {
560 use super::tokcfg::ChatTemplate;
561 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
562
563 let path = std::env::var("GPTOSS_CHAT_TEMPLATE").expect(
564 "set GPTOSS_CHAT_TEMPLATE to the tokenizer_config.json, chat_template.jinja, or model dir path",
565 );
566 let input_path = std::path::Path::new(&path);
567 let file_path = if input_path.is_dir() {
568 input_path.join("tokenizer_config.json")
570 } else {
571 input_path.to_path_buf()
572 };
573 let raw = std::fs::read_to_string(&file_path).expect("read chat template file");
574 let template_string: String = match serde_json::from_str::<serde_json::Value>(&raw) {
581 Ok(v) if v.get("chat_template").is_some() => v["chat_template"]
582 .as_str()
583 .expect("chat_template field must be a string")
584 .to_string(),
585 _ => {
586 let sibling = std::path::Path::new(&path)
587 .parent()
588 .map(|d| d.join("chat_template.jinja"));
589 match sibling {
590 Some(p) if p.exists() => {
591 eprintln!(
592 "[info] {path} had no chat_template field; using {}",
593 p.display()
594 );
595 std::fs::read_to_string(&p).expect("read sibling chat_template.jinja")
596 }
597 _ => raw,
598 }
599 }
600 };
601
602 assert!(
605 template_string.contains("{%") || template_string.contains("{{"),
606 "resolved template has no Jinja tags — GPTOSS_CHAT_TEMPLATE ({path}) is probably \
607 tokenizer_config.json with no chat_template field and no sibling chat_template.jinja. \
608 Point it at the chat_template.jinja file."
609 );
610
611 let chat_template: ChatTemplate =
612 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
613 .unwrap();
614
615 let formatter =
616 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
617
618 let request: NvCreateChatCompletionRequest = serde_json::from_str(
620 r#"{
621 "model": "openai/gpt-oss-120b",
622 "messages": [{"role":"user","content":"Search the repo for the string \"countHook\"."}],
623 "tools": [
624 {"type":"function","function":{"name":"grep","description":"search files","parameters":{"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}}},
625 {"type":"function","function":{"name":"read","description":"read a file","parameters":{"type":"object","properties":{"filePath":{"type":"string"}},"required":["filePath"]}}}
626 ]
627 }"#,
628 )
629 .unwrap();
630
631 let rendered = formatter.render(&request).unwrap();
632 eprintln!("================ RENDERED gpt-oss PROMPT (tools declared) ================");
633 eprintln!("{rendered}");
634 eprintln!("================ END RENDERED PROMPT ================");
635 eprintln!("[diagnostics] does the rendered prompt contain…");
636 for needle in [
637 "commentary",
638 "Calls to these tools",
639 "functions",
640 "# Tools",
641 "<|channel|>",
642 "constrain",
643 "analysis",
644 ] {
645 eprintln!(
646 " {:>22}: {}",
647 format!("{needle:?}"),
648 rendered.contains(needle)
649 );
650 }
651 }
652
653 #[test]
655 fn test_convert_media_url_to_placeholder_single_type() {
656 let content_array = vec![
657 serde_json::json!({"type": "text", "text": "Check this image:"}),
658 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
659 serde_json::json!({"type": "text", "text": "What do you see?"}),
660 ];
661
662 let conversions = &[("image_url", "image")];
663 let result = convert_media_url_to_placeholder(&content_array, conversions);
664
665 assert_eq!(result.len(), 3);
666 assert_eq!(result[0]["type"], "text");
668 assert_eq!(result[0]["text"], "Check this image:");
669 assert_eq!(result[1]["type"], "image");
671 assert!(result[1].get("image_url").is_none());
672 assert_eq!(result[2]["type"], "text");
674 assert_eq!(result[2]["text"], "What do you see?");
675 }
676
677 #[test]
679 fn test_convert_media_url_to_placeholder_multiple_same_type() {
680 let content_array = vec![
681 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}}),
682 serde_json::json!({"type": "text", "text": "vs"}),
683 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}),
684 ];
685
686 let conversions = &[("image_url", "image")];
687 let result = convert_media_url_to_placeholder(&content_array, conversions);
688
689 assert_eq!(result.len(), 3);
690 assert_eq!(result[0]["type"], "image");
691 assert_eq!(result[1]["type"], "text");
692 assert_eq!(result[2]["type"], "image");
693 }
694
695 #[test]
697 fn test_convert_media_url_to_placeholder_selective_conversion() {
698 let content_array = vec![
699 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
700 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
701 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
702 ];
703
704 let conversions = &[("image_url", "image")];
706 let result = convert_media_url_to_placeholder(&content_array, conversions);
707
708 assert_eq!(result.len(), 3);
709 assert_eq!(result[0]["type"], "audio_url");
711 assert!(result[0].get("audio_url").is_some());
712 assert_eq!(result[1]["type"], "video_url");
713 assert!(result[1].get("video_url").is_some());
714 assert_eq!(result[2]["type"], "image");
716 assert!(result[2].get("image_url").is_none());
717 }
718
719 #[test]
721 fn test_convert_media_url_to_placeholder_multiple_types() {
722 let content_array = vec![
723 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
724 serde_json::json!({"type": "text", "text": "and listen to"}),
725 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
726 serde_json::json!({"type": "text", "text": "and watch"}),
727 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
728 ];
729
730 let conversions = &[
732 ("image_url", "image"),
733 ("audio_url", "audio"),
734 ("video_url", "video"),
735 ];
736 let result = convert_media_url_to_placeholder(&content_array, conversions);
737
738 assert_eq!(result.len(), 5);
739 assert_eq!(result[0]["type"], "image");
740 assert!(result[0].get("image_url").is_none());
741 assert_eq!(result[1]["type"], "text");
742 assert_eq!(result[2]["type"], "audio");
743 assert!(result[2].get("audio_url").is_none());
744 assert_eq!(result[3]["type"], "text");
745 assert_eq!(result[4]["type"], "video");
746 assert!(result[4].get("video_url").is_none());
747 }
748
749 #[test]
751 fn test_convert_media_url_to_placeholder_no_conversions() {
752 let content_array = vec![
753 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
754 serde_json::json!({"type": "text", "text": "hello"}),
755 ];
756
757 let conversions: &[(&str, &str)] = &[];
758 let result = convert_media_url_to_placeholder(&content_array, conversions);
759
760 assert_eq!(result.len(), 2);
761 assert_eq!(result[0]["type"], "image_url");
763 assert!(result[0].get("image_url").is_some());
764 assert_eq!(result[1]["type"], "text");
765 }
766
767 #[test]
770 fn test_default_media_type_conversions_only_converts_image_url() {
771 let content_array = vec![
772 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
773 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
774 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
775 serde_json::json!({"type": "text", "text": "hello"}),
776 ];
777
778 let result =
780 convert_media_url_to_placeholder(&content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
781
782 assert_eq!(result.len(), 4);
783
784 assert_eq!(result[0]["type"], "image");
786 assert!(result[0].get("image_url").is_none());
787
788 assert_eq!(result[1]["type"], "video");
790 assert!(result[1].get("video_url").is_none());
791
792 assert_eq!(result[2]["type"], "audio");
794 assert!(result[2].get("audio_url").is_none());
795
796 assert_eq!(result[3]["type"], "text");
798 assert_eq!(result[3]["text"], "hello");
799 }
800
801 #[test]
802 fn test_may_be_fix_tool_schema_missing_type_and_properties() {
803 let json_str = r#"{
804 "model": "gpt-4o",
805 "messages": [],
806 "tools": [
807 {
808 "type": "function",
809 "function": {
810 "name": "get_weather",
811 "description": "Get the current weather in a given location",
812 "parameters": {},
813 "strict": null
814 }
815 }
816 ]
817 }"#;
818
819 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
820 let tools = serde_json::to_value(request.tools()).unwrap();
821
822 assert!(tools[0]["function"]["parameters"]["type"] == "object");
823 assert!(
824 tools[0]["function"]["parameters"]["properties"]
825 == serde_json::Value::Object(Default::default())
826 );
827 }
828
829 #[test]
830 fn test_may_be_fix_tool_schema_missing_type() {
831 let json_str = r#"{
832 "model": "gpt-4o",
833 "messages": [],
834 "tools": [
835 {
836 "type": "function",
837 "function": {
838 "name": "get_weather",
839 "description": "Get the current weather in a given location",
840 "parameters": {
841 "properties": {
842 "location": {
843 "type": "string",
844 "description": "City and state, e.g., 'San Francisco, CA'"
845 }
846 }
847 },
848 "strict": null
849 }
850 }
851 ]
852 }"#;
853 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
854
855 let tools = serde_json::to_value(request.tools()).unwrap();
856
857 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
858
859 let mut expected_properties = serde_json::Map::new();
860 let mut location = serde_json::Map::new();
861 location.insert(
862 "type".to_string(),
863 serde_json::Value::String("string".to_string()),
864 );
865 location.insert(
866 "description".to_string(),
867 serde_json::Value::String("City and state, e.g., 'San Francisco, CA'".to_string()),
868 );
869 expected_properties.insert("location".to_string(), serde_json::Value::Object(location));
870
871 assert_eq!(
872 tools[0]["function"]["parameters"]["properties"],
873 serde_json::Value::Object(expected_properties)
874 );
875 }
876
877 #[test]
878 fn test_may_be_fix_tool_schema_missing_properties() {
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": {"type": "object"},
889 "strict": null
890 }
891 }
892 ]
893 }"#;
894
895 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
896 let tools = serde_json::to_value(request.tools()).unwrap();
897
898 assert_eq!(
899 tools[0]["function"]["parameters"]["properties"],
900 serde_json::Value::Object(Default::default())
901 );
902 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
903 }
904
905 #[test]
906 fn test_may_be_fix_tool_schema_missing_description() {
907 let json_str = r#"{
911 "model": "gpt-4o",
912 "messages": [],
913 "tools": [
914 {
915 "type": "function",
916 "function": {
917 "name": "noop",
918 "parameters": {
919 "type": "object",
920 "properties": { "x": { "type": "string" } },
921 "required": ["x"],
922 "additionalProperties": false
923 },
924 "strict": null
925 }
926 }
927 ]
928 }"#;
929
930 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
931 let tools = serde_json::to_value(request.tools()).unwrap();
932
933 assert_eq!(
934 tools[0]["function"]["description"],
935 serde_json::Value::String(String::new())
936 );
937 }
938
939 #[test]
940 fn test_may_be_fix_tool_schema_null_description() {
941 let json_str = r#"{
943 "model": "gpt-4o",
944 "messages": [],
945 "tools": [
946 {
947 "type": "function",
948 "function": {
949 "name": "noop",
950 "description": null,
951 "parameters": {"type": "object", "properties": {}},
952 "strict": null
953 }
954 }
955 ]
956 }"#;
957
958 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
959 let tools = serde_json::to_value(request.tools()).unwrap();
960
961 assert_eq!(
962 tools[0]["function"]["description"],
963 serde_json::Value::String(String::new())
964 );
965 }
966
967 #[test]
968 fn test_may_be_fix_tool_schema_preserves_description() {
969 let json_str = r#"{
971 "model": "gpt-4o",
972 "messages": [],
973 "tools": [
974 {
975 "type": "function",
976 "function": {
977 "name": "get_weather",
978 "description": "Get the current weather in a given location",
979 "parameters": {"type": "object", "properties": {}},
980 "strict": null
981 }
982 }
983 ]
984 }"#;
985
986 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
987 let tools = serde_json::to_value(request.tools()).unwrap();
988
989 assert_eq!(
990 tools[0]["function"]["description"],
991 "Get the current weather in a given location"
992 );
993 }
994
995 #[test]
997 fn test_may_be_fix_msg_content_user_multipart() {
998 let json_str = r#"{
999 "model": "gpt-4o",
1000 "messages": [
1001 {
1002 "role": "user",
1003 "content": [
1004 {"type": "text", "text": "part 1"},
1005 {"type": "text", "text": "part 2"}
1006 ]
1007 }
1008 ]
1009 }"#;
1010
1011 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1012 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1013
1014 let messages =
1016 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1017
1018 assert_eq!(
1020 messages[0]["content"],
1021 serde_json::Value::String("part 1\npart 2".to_string())
1022 );
1023 }
1024
1025 #[test]
1028 fn test_may_be_fix_msg_content_mixed_messages() {
1029 let json_str = r#"{
1030 "model": "gpt-4o",
1031 "messages": [
1032 {
1033 "role": "system",
1034 "content": "You are a helpful assistant"
1035 },
1036 {
1037 "role": "user",
1038 "content": [
1039 {"type": "text", "text": "Hello"},
1040 {"type": "text", "text": "World"}
1041 ]
1042 },
1043 {
1044 "role": "assistant",
1045 "content": "Hi there!"
1046 },
1047 {
1048 "role": "user",
1049 "content": [
1050 {"type": "text", "text": "Another"},
1051 {"type": "text", "text": "multi-part"},
1052 {"type": "text", "text": "message"}
1053 ]
1054 }
1055 ]
1056 }"#;
1057
1058 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1059 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1060
1061 let messages =
1063 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1064
1065 assert_eq!(
1067 messages[0]["content"],
1068 serde_json::Value::String("You are a helpful assistant".to_string())
1069 );
1070
1071 assert_eq!(
1073 messages[1]["content"],
1074 serde_json::Value::String("Hello\nWorld".to_string())
1075 );
1076
1077 assert_eq!(
1079 messages[2]["content"],
1080 serde_json::Value::String("Hi there!".to_string())
1081 );
1082
1083 assert_eq!(
1085 messages[3]["content"],
1086 serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
1087 );
1088 }
1089
1090 #[test]
1092 fn test_may_be_fix_msg_content_empty_array() {
1093 let json_str = r#"{
1094 "model": "gpt-4o",
1095 "messages": [
1096 {
1097 "role": "user",
1098 "content": []
1099 }
1100 ]
1101 }"#;
1102
1103 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1104 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1105
1106 let messages =
1108 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1109
1110 assert!(messages[0]["content"].is_array());
1112 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1113 }
1114
1115 #[test]
1122 fn test_may_be_fix_msg_content_empty_array_with_placeholder_template() {
1123 let json_str = r#"{
1124 "model": "phi-3-vision",
1125 "messages": [
1126 {
1127 "role": "user",
1128 "content": []
1129 }
1130 ]
1131 }"#;
1132
1133 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1134 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1135
1136 let messages = serde_json::to_value(may_be_fix_msg_content(
1139 messages_raw,
1140 false,
1141 Some("<|image_{n}|>"),
1142 ))
1143 .unwrap();
1144
1145 assert!(
1146 messages[0]["content"].is_array(),
1147 "empty array should be preserved as `[]`, not flattened to `\"\"`"
1148 );
1149 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1150 }
1151
1152 #[test]
1154 fn test_may_be_fix_msg_content_single_text() {
1155 let json_str = r#"{
1156 "model": "gpt-4o",
1157 "messages": [
1158 {
1159 "role": "user",
1160 "content": "Simple text message"
1161 }
1162 ]
1163 }"#;
1164
1165 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1166 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1167
1168 let messages =
1170 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1171
1172 assert_eq!(
1174 messages[0]["content"],
1175 serde_json::Value::String("Simple text message".to_string())
1176 );
1177 }
1178
1179 #[test]
1182 fn test_may_be_fix_msg_content_mixed_types() {
1183 let json_str = r#"{
1184 "model": "gpt-4o",
1185 "messages": [
1186 {
1187 "role": "user",
1188 "content": [
1189 {"type": "text", "text": "Check this image:"},
1190 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
1191 {"type": "text", "text": "What do you see?"}
1192 ]
1193 }
1194 ]
1195 }"#;
1196
1197 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1198 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1199
1200 let messages =
1202 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1203
1204 assert!(messages[0]["content"].is_array());
1207 let content_array = messages[0]["content"].as_array().unwrap();
1208 assert_eq!(content_array.len(), 3);
1209 assert_eq!(content_array[0]["type"], "text");
1210 assert_eq!(content_array[1]["type"], "image");
1211 assert!(content_array[1].get("image_url").is_none());
1212 assert_eq!(content_array[2]["type"], "text");
1213 }
1214
1215 #[test]
1221 fn test_may_be_fix_msg_content_flattens_phi3_style() {
1222 let json_str = r#"{
1223 "model": "phi-3-vision",
1224 "messages": [
1225 {
1226 "role": "user",
1227 "content": [
1228 {"type": "text", "text": "First "},
1229 {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}},
1230 {"type": "text", "text": " then "},
1231 {"type": "image_url", "image_url": {"url": "https://example.com/b.jpg"}},
1232 {"type": "text", "text": "?"}
1233 ]
1234 }
1235 ]
1236 }"#;
1237 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1238 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1239
1240 let messages = serde_json::to_value(may_be_fix_msg_content(
1241 messages_raw,
1242 false,
1243 Some("<|image_{n}|>"),
1244 ))
1245 .unwrap();
1246
1247 let content = messages[0]["content"].as_str().expect("content flattened");
1248 assert_eq!(content, "First <|image_1|> then <|image_2|>?");
1249 }
1250
1251 #[test]
1253 fn test_may_be_fix_msg_content_flattens_llava_style() {
1254 let json_str = r#"{
1255 "model": "llava-1.5-7b-hf",
1256 "messages": [
1257 {
1258 "role": "user",
1259 "content": [
1260 {"type": "text", "text": "Describe: "},
1261 {"type": "image_url", "image_url": {"url": "https://example.com/x.jpg"}}
1262 ]
1263 }
1264 ]
1265 }"#;
1266 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1267 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1268
1269 let messages =
1270 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some("<image>")))
1271 .unwrap();
1272
1273 let content = messages[0]["content"].as_str().expect("content flattened");
1274 assert_eq!(content, "Describe: <image>");
1275 }
1276
1277 #[test]
1283 fn test_may_be_fix_msg_content_flattens_empty_placeholder() {
1284 let json_str = r#"{
1285 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1286 "messages": [
1287 {
1288 "role": "user",
1289 "content": [
1290 {"type": "text", "text": "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"},
1291 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1292 ]
1293 }
1294 ]
1295 }"#;
1296 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1297 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1298
1299 let messages =
1300 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some(""))).unwrap();
1301
1302 let content = messages[0]["content"].as_str().expect("content flattened");
1303 assert_eq!(
1304 content,
1305 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
1306 );
1307 }
1308
1309 #[test]
1316 fn test_render_nemotron_parse_passthrough() {
1317 use super::super::tokcfg::ChatTemplate;
1318 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1319
1320 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1321 "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}"
1322 }))
1323 .unwrap();
1324 let formatter =
1325 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1326
1327 for prompt in [
1328 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>",
1329 "</s><s><predict_bbox><predict_classes><output_markdown><predict_text_in_pic>",
1330 ] {
1331 let request: NvCreateChatCompletionRequest =
1332 serde_json::from_value(serde_json::json!({
1333 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1334 "messages": [{
1335 "role": "user",
1336 "content": [
1337 {"type": "text", "text": prompt},
1338 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1339 ]
1340 }]
1341 }))
1342 .unwrap();
1343
1344 let rendered = formatter.render(&request).unwrap();
1345 assert_eq!(
1346 rendered, prompt,
1347 "rendered prompt must be the control tokens only, with no JSON-serialized image array"
1348 );
1349 }
1350 }
1351
1352 #[test]
1355 fn test_may_be_fix_msg_content_non_text_only() {
1356 let json_str = r#"{
1357 "model": "gpt-4o",
1358 "messages": [
1359 {
1360 "role": "user",
1361 "content": [
1362 {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}},
1363 {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}
1364 ]
1365 }
1366 ]
1367 }"#;
1368
1369 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1370 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1371
1372 let messages =
1374 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1375
1376 assert!(messages[0]["content"].is_array());
1378 let content_array = messages[0]["content"].as_array().unwrap();
1379 assert_eq!(content_array.len(), 2);
1380 assert_eq!(content_array[0]["type"], "image");
1381 assert_eq!(content_array[1]["type"], "image");
1382 }
1383
1384 #[test]
1385 fn test_none_tools_safe_for_all_templates() {
1386 use super::tokcfg::ChatTemplate;
1387 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1388
1389 let length_template = r#"
1393{%- if tools is iterable and tools | length > 0 %}
1394Tools available: {{ tools | length }}
1395{%- else %}
1396No tools
1397{%- endif %}
1398"#;
1399
1400 let no_tool_template = r#"
1403{%- if tools is not none %}
1404TOOL MODE
1405{%- else %}
1406NORMAL MODE
1407{%- endif %}
1408"#;
1409
1410 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1411 "chat_template": [
1412 {"safe_length": length_template},
1413 {"no_tool": no_tool_template}
1414 ]
1415 }))
1416 .unwrap();
1417
1418 let formatter =
1419 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1420
1421 let ctx = context! { tools => Option::<Value>::None };
1422
1423 let result1 = formatter
1424 .env
1425 .get_template("safe_length")
1426 .unwrap()
1427 .render(&ctx);
1428 println!("Safe length template with no tools => None: {:?}", result1);
1429 assert!(
1430 result1.is_ok(),
1431 "Jinja template with and conditional and length filter should handle None: {:?}",
1432 result1
1433 );
1434 assert!(
1435 result1.unwrap().contains("No tools"),
1436 "Should show 'No tools'"
1437 );
1438
1439 let result2 = formatter.env.get_template("no_tool").unwrap().render(&ctx);
1440 println!("Default template with no tools => None: {:?}", result2);
1441 assert!(
1442 result2.is_ok(),
1443 "Jinja template with if tools is not none conditional should handle None: {:?}",
1444 result2
1445 );
1446 assert!(result2.unwrap().contains("NORMAL MODE"));
1447 }
1448
1449 #[test]
1451 fn test_may_be_fix_msg_content_multiple_content_types() {
1452 let json_str = r#"{
1454 "model": "gpt-4o",
1455 "messages": [
1456 {
1457 "role": "user",
1458 "content": [
1459 {"type": "text", "text": "Listen to this:"},
1460 {"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}},
1461 {"type": "text", "text": "And look at:"},
1462 {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
1463 {"type": "text", "text": "What do you think?"}
1464 ]
1465 }
1466 ]
1467 }"#;
1468
1469 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1470 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1471 let messages =
1472 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1473
1474 assert!(messages[0]["content"].is_array());
1476 let content_array = messages[0]["content"].as_array().unwrap();
1477 assert_eq!(content_array.len(), 5);
1478 assert_eq!(content_array[0]["type"], "text");
1479 assert_eq!(content_array[1]["type"], "audio");
1480 assert_eq!(content_array[2]["type"], "text");
1481 assert_eq!(content_array[3]["type"], "image");
1482 assert_eq!(content_array[4]["type"], "text");
1483
1484 let json_str = r#"{
1486 "model": "gpt-4o",
1487 "messages": [
1488 {
1489 "role": "user",
1490 "content": [
1491 {"type": "text", "text": "Check this:"},
1492 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1493 {"type": "text", "text": "Interesting?"}
1494 ]
1495 }
1496 ]
1497 }"#;
1498
1499 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1500 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1501 let messages =
1502 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1503
1504 assert!(messages[0]["content"].is_array());
1506 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1507 }
1508
1509 #[test]
1510 fn test_normalize_tool_arguments_tojson() {
1511 let tmpl = r#"{{ messages[0].tool_calls[0].function.arguments | tojson }}"#;
1512
1513 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1515 "role": "assistant",
1516 "tool_calls": [{
1517 "type": "function",
1518 "function": {
1519 "name": "get_current_weather",
1520 "arguments": "{\"format\":\"celsius\",\"location\":\"San Francisco, CA\"}"
1521 }
1522 }]
1523 })]);
1524
1525 normalize_tool_calls_arguments_in_messages(&mut messages);
1526
1527 let mut env = Environment::new();
1528 env.add_filter("tojson", super::super::tokcfg::tojson);
1529 env.add_template("t", tmpl).unwrap();
1530 let out = env
1531 .get_template("t")
1532 .unwrap()
1533 .render(context! { messages => messages.as_array().unwrap() })
1534 .unwrap();
1535
1536 assert_eq!(
1539 out,
1540 r#"{"format": "celsius", "location": "San Francisco, CA"}"#
1541 );
1542 }
1543
1544 #[test]
1545 fn test_normalize_tool_arguments_items_loop() {
1546 let tmpl = r#"{% for k, v in messages[0].tool_calls[0].function.arguments|items %}{{k}}={{v}};{% endfor %}"#;
1547
1548 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1549 "role": "assistant",
1550 "tool_calls": [{
1551 "type": "function",
1552 "function": {
1553 "name": "f",
1554 "arguments": "{\"a\":1,\"b\":\"x\"}"
1555 }
1556 }]
1557 })]);
1558
1559 normalize_tool_calls_arguments_in_messages(&mut messages);
1560
1561 let mut env = Environment::new();
1562 env.add_template("t", tmpl).unwrap();
1563 let out = env
1564 .get_template("t")
1565 .unwrap()
1566 .render(context! { messages => messages.as_array().unwrap() })
1567 .unwrap();
1568
1569 assert!(out == "a=1;b=x;" || out == "b=x;a=1;");
1570 }
1571
1572 #[test]
1573 fn test_normalize_tool_arguments_legacy_function_call() {
1574 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1576 "role": "assistant",
1577 "function_call": {
1578 "name": "get_weather",
1579 "arguments": "{\"location\":\"NYC\"}"
1580 }
1581 })]);
1582
1583 normalize_function_call_arguments_in_messages(&mut messages);
1584
1585 assert_eq!(
1586 messages[0]["function_call"]["arguments"],
1587 serde_json::json!({"location": "NYC"})
1588 );
1589 }
1590
1591 #[test]
1592 fn test_normalize_tool_arguments_malformed_json_passthrough() {
1593 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1595 "role": "assistant",
1596 "tool_calls": [{
1597 "type": "function",
1598 "function": {
1599 "name": "f",
1600 "arguments": "not valid json at all"
1601 }
1602 }]
1603 })]);
1604
1605 normalize_tool_calls_arguments_in_messages(&mut messages);
1606
1607 assert_eq!(
1608 messages[0]["tool_calls"][0]["function"]["arguments"],
1609 serde_json::Value::String("not valid json at all".to_string())
1610 );
1611 }
1612
1613 #[test]
1614 fn test_normalize_tool_arguments_with_multimodal_content() {
1615 let json_str = r#"{
1616 "model": "gpt-4o",
1617 "messages": [
1618 {
1619 "role": "user",
1620 "content": [
1621 {"type": "text", "text": "Check this:"},
1622 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1623 {"type": "text", "text": "Interesting?"}
1624 ]
1625 },
1626 {
1627 "role": "assistant",
1628 "tool_calls": [{
1629 "id": "call_123",
1630 "type": "function",
1631 "function": {
1632 "name": "analyze_video",
1633 "arguments": "{\"url\":\"https://example.com/vid.mp4\",\"format\":\"mp4\"}"
1634 }
1635 }]
1636 }
1637 ]
1638 }"#;
1639
1640 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1641 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1642
1643 let mut messages =
1645 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1646
1647 normalize_tool_calls_arguments_in_messages(&mut messages);
1648
1649 assert!(messages[0]["content"].is_array());
1651 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1652
1653 assert!(messages[1]["tool_calls"][0]["function"]["arguments"].is_object());
1655 assert_eq!(
1656 messages[1]["tool_calls"][0]["function"]["arguments"]["url"],
1657 "https://example.com/vid.mp4"
1658 );
1659 }
1660
1661 #[test]
1663 fn test_may_be_fix_msg_content_string_to_array() {
1664 let json_str = r#"{
1665 "model": "gpt-4o",
1666 "messages": [
1667 {
1668 "role": "user",
1669 "content": "Hello, how are you?"
1670 }
1671 ]
1672 }"#;
1673
1674 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1675 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1676
1677 let messages =
1679 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1680
1681 assert!(messages[0]["content"].is_array());
1683 let content_array = messages[0]["content"].as_array().unwrap();
1684 assert_eq!(content_array.len(), 1);
1685 assert_eq!(content_array[0]["type"], "text");
1686 assert_eq!(content_array[0]["text"], "Hello, how are you?");
1687 }
1688
1689 #[test]
1691 fn test_may_be_fix_msg_content_array_preserved_with_multimodal() {
1692 let json_str = r#"{
1693 "model": "gpt-4o",
1694 "messages": [
1695 {
1696 "role": "user",
1697 "content": [
1698 {"type": "text", "text": "part 1"},
1699 {"type": "text", "text": "part 2"}
1700 ]
1701 }
1702 ]
1703 }"#;
1704
1705 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1706 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1707
1708 let messages =
1710 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1711
1712 assert!(messages[0]["content"].is_array());
1714 let content_array = messages[0]["content"].as_array().unwrap();
1715 assert_eq!(content_array.len(), 2);
1716 assert_eq!(content_array[0]["text"], "part 1");
1717 assert_eq!(content_array[1]["text"], "part 2");
1718 }
1719
1720 fn user() -> Msg {
1721 Msg::User(Default::default())
1722 }
1723 fn tool() -> Msg {
1724 Msg::Tool(Default::default())
1725 }
1726
1727 fn dummy_state(messages: Vec<Msg>) -> NvCreateChatCompletionRequest {
1728 let json = serde_json::json!({
1729 "model": "test-model",
1730 "messages": messages
1731 });
1732 serde_json::from_value(json).unwrap()
1733 }
1734
1735 #[test]
1736 fn add_after_user() {
1737 let s = dummy_state(vec![user()]);
1738 assert!(s.should_add_generation_prompt());
1739 }
1740
1741 #[test]
1742 fn add_after_tool() {
1743 let s = dummy_state(vec![tool()]);
1744 assert!(s.should_add_generation_prompt());
1745 }
1746
1747 #[test]
1748 fn add_when_empty() {
1749 let s = dummy_state(vec![]);
1750 assert!(s.should_add_generation_prompt());
1751 }
1752
1753 fn tool_aware_formatter(
1755 exclude_tools_when_tool_choice_none: bool,
1756 ) -> HfTokenizerConfigJsonFormatter {
1757 let template = r#"
1758{%- if tools is iterable and tools | length > 0 %}
1759TOOL_MODE tools={{ tools | length }}
1760{%- else %}
1761NORMAL_MODE
1762{%- endif %}
1763{{ messages[0].content }}"#;
1764
1765 let chat_template: super::tokcfg::ChatTemplate =
1766 serde_json::from_value(serde_json::json!({ "chat_template": template })).unwrap();
1767
1768 HfTokenizerConfigJsonFormatter::with_options(
1769 chat_template,
1770 ContextMixins::new(&[]),
1771 exclude_tools_when_tool_choice_none,
1772 )
1773 .unwrap()
1774 }
1775
1776 fn request_with_tool_choice(tool_choice: &str) -> NvCreateChatCompletionRequest {
1778 serde_json::from_value(serde_json::json!({
1779 "model": "test",
1780 "messages": [{"role": "user", "content": "hello"}],
1781 "tools": [{
1782 "type": "function",
1783 "function": {
1784 "name": "get_weather",
1785 "description": "Get weather",
1786 "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}
1787 }
1788 }],
1789 "tool_choice": tool_choice
1790 }))
1791 .unwrap()
1792 }
1793
1794 #[test]
1795 fn test_exclude_tools_strips_when_tool_choice_none() {
1796 let formatter = tool_aware_formatter(true);
1797 let request = request_with_tool_choice("none");
1798 let result = formatter.render(&request).unwrap();
1799 assert!(
1800 result.contains("NORMAL_MODE"),
1801 "With exclude_tools=true and tool_choice=none, tools should be stripped. Got: {}",
1802 result
1803 );
1804 }
1805
1806 #[test]
1807 fn test_exclude_tools_keeps_when_tool_choice_auto() {
1808 let formatter = tool_aware_formatter(true);
1809 let request = request_with_tool_choice("auto");
1810 let result = formatter.render(&request).unwrap();
1811 assert!(
1812 result.contains("TOOL_MODE"),
1813 "With tool_choice=auto, tools should be included. Got: {}",
1814 result
1815 );
1816 }
1817
1818 #[test]
1819 fn test_no_exclude_tools_keeps_when_tool_choice_none() {
1820 let formatter = tool_aware_formatter(false);
1821 let request = request_with_tool_choice("none");
1822 let result = formatter.render(&request).unwrap();
1823 assert!(
1824 result.contains("TOOL_MODE"),
1825 "With exclude_tools=false and tool_choice=none, tools should NOT be stripped. Got: {}",
1826 result
1827 );
1828 }
1829
1830 #[test]
1831 fn test_inject_reasoning_content_segments_with_tool_calls() {
1832 let mut messages = serde_json::json!([
1834 {
1835 "role": "user",
1836 "content": "What is sqrt(144) and sqrt(256)?"
1837 },
1838 {
1839 "role": "assistant",
1840 "content": "Let me calculate those.",
1841 "reasoning_content": ["I need to compute sqrt(144)", "Now sqrt(256)", ""],
1842 "tool_calls": [
1843 {
1844 "id": "call_0",
1845 "type": "function",
1846 "function": {
1847 "name": "calculator",
1848 "arguments": "{\"expr\": \"sqrt(144)\"}"
1849 }
1850 },
1851 {
1852 "id": "call_1",
1853 "type": "function",
1854 "function": {
1855 "name": "calculator",
1856 "arguments": "{\"expr\": \"sqrt(256)\"}"
1857 }
1858 }
1859 ]
1860 }
1861 ]);
1862
1863 inject_reasoning_content_into_messages(&mut messages);
1864
1865 let assistant = &messages[1];
1866
1867 assert!(
1869 assistant.get("reasoning_content").is_none(),
1870 "reasoning_content should be removed after injection"
1871 );
1872
1873 let content = assistant["content"].as_str().unwrap();
1875 assert!(
1876 content.starts_with("<think>I need to compute sqrt(144)</think>"),
1877 "content should start with first reasoning segment, got: {}",
1878 content
1879 );
1880 assert!(
1881 content.contains("<think>Now sqrt(256)</think>"),
1882 "content should contain second reasoning segment"
1883 );
1884 assert!(
1886 !content.contains("<think></think>"),
1887 "empty segments should be skipped"
1888 );
1889 assert!(
1891 content.ends_with("Let me calculate those."),
1892 "original content should be at the end, got: {}",
1893 content
1894 );
1895
1896 assert!(assistant.get("tool_calls").is_some());
1898 assert_eq!(assistant["tool_calls"].as_array().unwrap().len(), 2);
1899 }
1900
1901 #[test]
1902 fn test_inject_reasoning_content_text_variant() {
1903 let mut messages = serde_json::json!([
1904 {
1905 "role": "assistant",
1906 "content": "The answer is 42.",
1907 "reasoning_content": "Let me think about this carefully."
1908 }
1909 ]);
1910
1911 inject_reasoning_content_into_messages(&mut messages);
1912
1913 let assistant = &messages[0];
1914 assert!(assistant.get("reasoning_content").is_none());
1915 let content = assistant["content"].as_str().unwrap();
1916 assert_eq!(
1917 content,
1918 "<think>Let me think about this carefully.</think>The answer is 42."
1919 );
1920 }
1921
1922 #[test]
1923 fn test_inject_reasoning_content_null_content() {
1924 let mut messages = serde_json::json!([
1926 {
1927 "role": "assistant",
1928 "content": null,
1929 "reasoning_content": "Thinking...",
1930 "tool_calls": [{"id": "call_0", "type": "function", "function": {"name": "f", "arguments": "{}"}}]
1931 }
1932 ]);
1933
1934 inject_reasoning_content_into_messages(&mut messages);
1935
1936 let content = messages[0]["content"].as_str().unwrap();
1937 assert_eq!(content, "<think>Thinking...</think>");
1938 assert!(messages[0].get("reasoning_content").is_none());
1939 }
1940
1941 #[test]
1942 fn test_inject_reasoning_content_skips_non_assistant() {
1943 let mut messages = serde_json::json!([
1944 {
1945 "role": "user",
1946 "content": "hello",
1947 "reasoning_content": "should not be touched"
1948 }
1949 ]);
1950
1951 inject_reasoning_content_into_messages(&mut messages);
1952
1953 assert!(messages[0].get("reasoning_content").is_some());
1955 }
1956
1957 fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
1959 use super::tokcfg::ChatTemplate;
1960 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1961
1962 let template = r#"{%- for message in messages %}{{ message.role }}: {{ message.content }}
1965{%- endfor %}
1966{%- if add_generation_prompt %}assistant:{%- endif %}"#;
1967
1968 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1969 "chat_template": template
1970 }))
1971 .unwrap();
1972
1973 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
1974 }
1975
1976 #[test]
1979 fn test_reasoning_content_text_roundtrip_render() {
1980 use super::OAIPromptFormatter;
1981 let formatter = make_test_formatter();
1982
1983 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
1984 "model": "test-model",
1985 "messages": [
1986 {"role": "user", "content": "What is sqrt(144)?"},
1987 {
1988 "role": "assistant",
1989 "content": "The answer is 12.",
1990 "reasoning_content": "I need to compute the square root of 144."
1991 },
1992 {"role": "user", "content": "Are you sure?"}
1993 ]
1994 }))
1995 .unwrap();
1996
1997 let rendered = formatter.render(&request).unwrap();
1998
1999 assert!(
2000 rendered.contains("<think>I need to compute the square root of 144.</think>"),
2001 "reasoning_content must appear as <think> block, got: {}",
2002 rendered
2003 );
2004 assert!(
2005 rendered.contains("The answer is 12."),
2006 "original content must be preserved"
2007 );
2008 assert!(
2009 !rendered.contains("reasoning_content"),
2010 "raw reasoning_content field should not leak into prompt"
2011 );
2012 }
2013
2014 #[test]
2018 fn test_reasoning_content_agentic_tool_call_roundtrip_render() {
2019 use super::OAIPromptFormatter;
2020 let formatter = make_test_formatter();
2021
2022 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2023 "model": "test-model",
2024 "messages": [
2025 {"role": "user", "content": "What is sqrt(144) + sqrt(256)?"},
2026 {
2027 "role": "assistant",
2028 "content": null,
2029 "reasoning_content": "I need to compute both square roots. Let me start with sqrt(144).",
2030 "tool_calls": [{
2031 "id": "call_0",
2032 "type": "function",
2033 "function": {
2034 "name": "calculator",
2035 "arguments": "{\"expr\": \"sqrt(144)\"}"
2036 }
2037 }]
2038 },
2039 {
2040 "role": "tool",
2041 "tool_call_id": "call_0",
2042 "content": "12"
2043 },
2044 {
2045 "role": "assistant",
2046 "content": "sqrt(144) = 12 and sqrt(256) = 16, so the answer is 28.",
2047 "reasoning_content": "Got 12 for sqrt(144). Now sqrt(256) = 16. Sum is 28."
2048 },
2049 {"role": "user", "content": "Thanks!"}
2050 ]
2051 }))
2052 .unwrap();
2053
2054 let rendered = formatter.render(&request).unwrap();
2055
2056 assert!(
2058 rendered.contains("<think>I need to compute both square roots"),
2059 "first turn reasoning must be in prompt, got: {}",
2060 rendered
2061 );
2062 assert!(
2064 rendered.contains("<think>Got 12 for sqrt(144)"),
2065 "second turn reasoning must be in prompt"
2066 );
2067 assert!(
2068 rendered.contains("the answer is 28"),
2069 "final answer content must be preserved"
2070 );
2071 assert!(
2073 !rendered.contains("reasoning_content"),
2074 "raw reasoning_content field should not leak into prompt"
2075 );
2076 }
2077
2078 #[test]
2080 fn test_reasoning_injected_when_template_ignores_it() {
2081 use super::OAIPromptFormatter;
2082 let formatter = make_test_formatter();
2083
2084 assert!(!formatter.template_handles_reasoning);
2086
2087 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2088 "model": "test-model",
2089 "messages": [
2090 {"role": "user", "content": "Hello"},
2091 {
2092 "role": "assistant",
2093 "content": "Hi.",
2094 "reasoning_content": "The user said hello."
2095 },
2096 {"role": "user", "content": "Bye"}
2097 ]
2098 }))
2099 .unwrap();
2100
2101 let rendered = formatter.render(&request).unwrap();
2102 assert!(
2103 rendered.contains("<think>The user said hello.</think>"),
2104 "injection must happen when template ignores reasoning_content, got: {}",
2105 rendered
2106 );
2107 }
2108
2109 #[test]
2111 fn test_reasoning_not_injected_when_template_handles_it() {
2112 use super::tokcfg::ChatTemplate;
2113 use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
2114
2115 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>
2117{%- endif %}{{ message.role }}: {{ message.content }}
2118{%- endfor %}
2119{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2120
2121 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2122 "chat_template": template
2123 }))
2124 .unwrap();
2125
2126 let formatter =
2127 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2128
2129 assert!(formatter.template_handles_reasoning);
2131
2132 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2133 "model": "test-model",
2134 "messages": [
2135 {"role": "user", "content": "Hello"},
2136 {
2137 "role": "assistant",
2138 "content": "Hi.",
2139 "reasoning_content": "The user said hello."
2140 },
2141 {"role": "user", "content": "Bye"}
2142 ]
2143 }))
2144 .unwrap();
2145
2146 let rendered = formatter.render(&request).unwrap();
2147
2148 assert!(
2150 rendered.contains("<think>The user said hello.</think>"),
2151 "template must render reasoning_content natively, got: {}",
2152 rendered
2153 );
2154 let think_count = rendered.matches("<think>").count();
2156 assert_eq!(
2157 think_count, 1,
2158 "must have exactly one <think> block (from template), got {} in: {}",
2159 think_count, rendered
2160 );
2161 }
2162
2163 const QWEN3_THINKING_TEMPLATE: &str = r##"{%- if tools %}
2167 {{- '<|im_start|>system\n' }}
2168 {%- if messages[0].role == 'system' %}
2169 {{- messages[0].content + '\n\n' }}
2170 {%- endif %}
2171 {{- "# 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>" }}
2172 {%- for tool in tools %}
2173 {{- "\n" }}
2174 {{- tool | tojson }}
2175 {%- endfor %}
2176 {{- "\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" }}
2177{%- else %}
2178 {%- if messages[0].role == 'system' %}
2179 {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
2180 {%- endif %}
2181{%- endif %}
2182{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
2183{%- for message in messages[::-1] %}
2184 {%- set index = (messages|length - 1) - loop.index0 %}
2185 {%- 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>')) %}
2186 {%- set ns.multi_step_tool = false %}
2187 {%- set ns.last_query_index = index %}
2188 {%- endif %}
2189{%- endfor %}
2190{%- for message in messages %}
2191 {%- if message.content is string %}
2192 {%- set content = message.content %}
2193 {%- else %}
2194 {%- set content = '' %}
2195 {%- endif %}
2196 {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
2197 {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
2198 {%- elif message.role == "assistant" %}
2199 {%- set reasoning_content = '' %}
2200 {%- if message.reasoning_content is string %}
2201 {%- set reasoning_content = message.reasoning_content %}
2202 {%- else %}
2203 {%- if '</think>' in content %}
2204 {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
2205 {%- set content = content.split('</think>')[-1].lstrip('\n') %}
2206 {%- endif %}
2207 {%- endif %}
2208 {%- if loop.index0 > ns.last_query_index %}
2209 {%- if loop.last or (not loop.last and reasoning_content) %}
2210 {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
2211 {%- else %}
2212 {{- '<|im_start|>' + message.role + '\n' + content }}
2213 {%- endif %}
2214 {%- else %}
2215 {{- '<|im_start|>' + message.role + '\n' + content }}
2216 {%- endif %}
2217 {%- if message.tool_calls %}
2218 {%- for tool_call in message.tool_calls %}
2219 {%- if (loop.first and content) or (not loop.first) %}
2220 {{- '\n' }}
2221 {%- endif %}
2222 {%- if tool_call.function %}
2223 {%- set tool_call = tool_call.function %}
2224 {%- endif %}
2225 {{- '<tool_call>\n{"name": "' }}
2226 {{- tool_call.name }}
2227 {{- '", "arguments": ' }}
2228 {%- if tool_call.arguments is string %}
2229 {{- tool_call.arguments }}
2230 {%- else %}
2231 {{- tool_call.arguments | tojson }}
2232 {%- endif %}
2233 {{- '}\n</tool_call>' }}
2234 {%- endfor %}
2235 {%- endif %}
2236 {{- '<|im_end|>\n' }}
2237 {%- elif message.role == "tool" %}
2238 {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
2239 {{- '<|im_start|>user' }}
2240 {%- endif %}
2241 {{- '\n<tool_response>\n' }}
2242 {{- content }}
2243 {{- '\n</tool_response>' }}
2244 {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
2245 {{- '<|im_end|>\n' }}
2246 {%- endif %}
2247 {%- endif %}
2248{%- endfor %}
2249{%- if add_generation_prompt %}
2250 {{- '<|im_start|>assistant\n<think>\n' }}
2251{%- endif %}"##;
2252
2253 fn qwen3_thinking_formatter() -> HfTokenizerConfigJsonFormatter {
2254 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2255 "chat_template": QWEN3_THINKING_TEMPLATE,
2256 }))
2257 .unwrap();
2258 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2259 }
2260
2261 #[test]
2262 fn test_qwen3_thinking_template_flags_detected() {
2263 let formatter = qwen3_thinking_formatter();
2264 assert!(
2265 formatter.template_handles_reasoning,
2266 "template references reasoning_content directly"
2267 );
2268 assert!(
2271 formatter.default_template_handles_tool_calls_arguments_string,
2272 "default template branches on `arguments is string`"
2273 );
2274 assert!(
2275 formatter.tool_use_template_handles_tool_calls_arguments_string,
2276 "tool_use template branches on `arguments is string`"
2277 );
2278 }
2279
2280 #[test]
2290 fn test_qwen3_thinking_append_only_across_tool_use_turn() {
2291 let formatter = qwen3_thinking_formatter();
2292
2293 let tools = serde_json::json!([{
2294 "type": "function",
2295 "function": {
2296 "name": "get_weather",
2297 "description": "Get the current weather for a location",
2298 "parameters": {
2299 "type": "object",
2300 "properties": {
2301 "location": {"type": "string"},
2302 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
2303 },
2304 "required": ["location"]
2305 }
2306 }
2307 }]);
2308
2309 let turn1_request: NvCreateChatCompletionRequest =
2311 serde_json::from_value(serde_json::json!({
2312 "model": "qwen3-thinking",
2313 "messages": [
2314 {"role": "system", "content": "You are a helpful assistant."},
2315 {"role": "user", "content": "What's the weather in San Francisco?"},
2316 ],
2317 "tools": tools,
2318 }))
2319 .unwrap();
2320 let p1 = formatter.render(&turn1_request).unwrap();
2321
2322 let model_emitted = "I'll call get_weather for SF.\n\
2326 </think>\n\n\
2327 <tool_call>\n\
2328 {\"name\": \"get_weather\", \"arguments\": {\"location\": \"San Francisco\", \"unit\": \"celsius\"}}\n\
2329 </tool_call><|im_end|>\n";
2330 let wire_after_t1 = format!("{p1}{model_emitted}");
2331
2332 let turn2_request: NvCreateChatCompletionRequest =
2335 serde_json::from_value(serde_json::json!({
2336 "model": "qwen3-thinking",
2337 "messages": [
2338 {"role": "system", "content": "You are a helpful assistant."},
2339 {"role": "user", "content": "What's the weather in San Francisco?"},
2340 {
2341 "role": "assistant",
2342 "content": "",
2343 "reasoning_content": "I'll call get_weather for SF.",
2344 "tool_calls": [{
2345 "id": "call_sf",
2346 "type": "function",
2347 "function": {
2348 "name": "get_weather",
2349 "arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
2350 }
2351 }]
2352 },
2353 {
2354 "role": "tool",
2355 "tool_call_id": "call_sf",
2356 "content": "{\"temp\": 18, \"conditions\": \"Foggy\"}"
2357 }
2358 ],
2359 "tools": tools,
2360 }))
2361 .unwrap();
2362 let p2 = formatter.render(&turn2_request).unwrap();
2363
2364 if !p2.starts_with(&wire_after_t1) {
2365 let div = wire_after_t1
2367 .as_bytes()
2368 .iter()
2369 .zip(p2.as_bytes())
2370 .position(|(a, b)| a != b)
2371 .unwrap_or_else(|| wire_after_t1.len().min(p2.len()));
2372 let lo = div.saturating_sub(40);
2373 panic!(
2374 "turn-2 prompt is NOT a prefix-extension of [turn-1 + model bytes]\n \
2375 diverges at byte {div}\n \
2376 wire ends: ...{}|{}\n \
2377 t2 has: ...{}|{}",
2378 String::from_utf8_lossy(&wire_after_t1.as_bytes()[lo..div]),
2379 String::from_utf8_lossy(
2380 &wire_after_t1.as_bytes()[div..(div + 60).min(wire_after_t1.len())]
2381 ),
2382 String::from_utf8_lossy(&p2.as_bytes()[lo..div]),
2383 String::from_utf8_lossy(&p2.as_bytes()[div..(div + 60).min(p2.len())]),
2384 );
2385 }
2386
2387 let suffix = &p2[wire_after_t1.len()..];
2390 assert!(
2391 suffix.contains("<tool_response>"),
2392 "appended bytes must include the tool response, got: {suffix}"
2393 );
2394 assert!(
2395 suffix.ends_with("<|im_start|>assistant\n<think>\n"),
2396 "appended bytes must end with the next generation prompt, got: {suffix}"
2397 );
2398 }
2399}