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