1use super::*;
5
6use crate::{OAIChatLikeRequest, TextInput};
7use minijinja::{context, value::Value};
8use serde_json::json;
9use std::result::Result::Ok;
10
11pub fn may_be_fix_tool_schema(tools: serde_json::Value) -> Option<Value> {
15 let mut updated_tools = Vec::new();
19 if let Some(arr) = tools.as_array() {
20 for tool in arr {
21 let mut tool = tool.clone();
22 if let Some(function) = tool.get_mut("function") {
23 if let Some(obj) = function.as_object_mut()
28 && !matches!(obj.get("description"), Some(serde_json::Value::String(_)))
29 {
30 obj.insert(
31 "description".to_string(),
32 serde_json::Value::String(String::new()),
33 );
34 }
35 }
36 if let Some(function) = tool.get_mut("function")
37 && let Some(parameters) = function.get_mut("parameters")
38 {
39 if parameters.is_object() {
41 let mut needs_type = false;
42 let mut needs_properties = false;
43 let is_empty = parameters
44 .as_object()
45 .map(|o| o.is_empty())
46 .unwrap_or(false);
47
48 if is_empty {
50 needs_type = true;
51 needs_properties = true;
52 } else {
53 if let Some(obj) = parameters.as_object() {
55 if !obj.contains_key("type") {
56 needs_type = true;
57 }
58 if !obj.contains_key("properties") {
59 needs_properties = true;
60 }
61 }
62 }
63
64 if (needs_type || needs_properties)
65 && let Some(obj) = parameters.as_object_mut()
66 {
67 if needs_type {
68 obj.insert(
69 "type".to_string(),
70 serde_json::Value::String("object".to_string()),
71 );
72 }
73 if needs_properties {
74 obj.insert(
75 "properties".to_string(),
76 serde_json::Value::Object(Default::default()),
77 );
78 }
79 }
80 }
81 }
82 updated_tools.push(tool);
83 }
84 }
85 Some(Value::from_serialize(&updated_tools))
86}
87
88const DEFAULT_MEDIA_TYPE_CONVERSIONS: &[(&str, &str)] = &[
91 ("image_url", "image"),
92 ("video_url", "video"),
93 ("audio_url", "audio"),
94];
95
96fn convert_media_url_to_placeholder(
98 content_array: &[serde_json::Value],
99 conversions: &[(&str, &str)],
100) -> Vec<serde_json::Value> {
101 content_array
102 .iter()
103 .map(|part| {
104 let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
105
106 if let Some((_, target_type)) = conversions.iter().find(|(src, _)| *src == part_type) {
107 serde_json::json!({"type": target_type})
108 } else {
109 part.clone()
110 }
111 })
112 .collect()
113}
114
115fn may_be_fix_msg_content(
116 messages: serde_json::Value,
117 preserve_arrays: bool,
118 image_placeholder_template: Option<&str>,
119) -> Value {
120 let Some(arr) = messages.as_array() else {
129 return Value::from_serialize(&messages);
130 };
131
132 let updated_messages: Vec<_> = arr
133 .iter()
134 .map(|msg| {
135 match msg.get("content") {
136 Some(serde_json::Value::String(text)) if preserve_arrays => {
138 let mut modified_msg = msg.clone();
139 if let Some(msg_object) = modified_msg.as_object_mut() {
140 let content_array = serde_json::json!([{
141 "type": "text",
142 "text": text
143 }]);
144 msg_object.insert("content".to_string(), content_array);
145 }
146 modified_msg
147 }
148 Some(serde_json::Value::Array(content_array)) => {
150 let content_array = convert_media_url_to_placeholder(
152 content_array,
153 DEFAULT_MEDIA_TYPE_CONVERSIONS,
154 );
155
156 let is_text_only_array = !content_array.is_empty()
158 && content_array.iter().all(|part| {
159 part.get("type")
160 .and_then(|type_field| type_field.as_str())
161 .map(|type_str| type_str == "text")
162 .unwrap_or(false)
163 });
164
165 let mut modified_msg = msg.clone();
166 if let Some(msg_object) = modified_msg.as_object_mut() {
167 if is_text_only_array && !preserve_arrays {
168 let text_parts: Vec<&str> = content_array
170 .iter()
171 .filter_map(|part| part.get("text")?.as_str())
172 .collect();
173 let concatenated_text = text_parts.join("\n");
174 msg_object.insert(
175 "content".to_string(),
176 serde_json::Value::String(concatenated_text),
177 );
178 } else if !preserve_arrays
179 && !content_array.is_empty()
180 && let Some(placeholder_tpl) = image_placeholder_template
181 {
182 let flattened = flatten_mixed_content(&content_array, placeholder_tpl);
195 msg_object.insert(
196 "content".to_string(),
197 serde_json::Value::String(flattened),
198 );
199 } else {
200 msg_object.insert(
202 "content".to_string(),
203 serde_json::Value::Array(content_array),
204 );
205 }
206 }
207 modified_msg
208 }
209 _ => msg.clone(), }
211 })
212 .collect();
213
214 Value::from_serialize(&updated_messages)
215}
216
217fn flatten_mixed_content(parts: &[serde_json::Value], placeholder_tpl: &str) -> String {
240 let mut out = String::new();
241 let mut img_idx: u32 = 1;
242 for part in parts {
243 let type_str = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
244 if type_str == "text" {
245 if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
246 out.push_str(text);
247 }
248 } else if !type_str.is_empty() {
249 let placeholder = placeholder_tpl.replace("{n}", &img_idx.to_string());
250 out.push_str(&placeholder);
251 img_idx += 1;
252 }
253 }
254 out
255}
256
257fn normalize_tool_calls_arguments_in_messages(messages: &mut serde_json::Value) {
258 let Some(msgs) = messages.as_array_mut() else {
263 return;
264 };
265
266 for msg in msgs.iter_mut() {
267 if let Some(tool_calls) = msg.get_mut("tool_calls").and_then(|v| v.as_array_mut()) {
268 for tc in tool_calls {
269 if let Some(function) = tc.get_mut("function").and_then(|v| v.as_object_mut())
270 && let Some(args) = function.get_mut("arguments")
271 && let Some(s) = args.as_str()
272 && let Ok(parsed) = serde_json::from_str(s)
273 {
274 *args = parsed;
275 }
276 }
277 }
278 }
279}
280
281fn normalize_function_call_arguments_in_messages(messages: &mut serde_json::Value) {
282 let Some(msgs) = messages.as_array_mut() else {
287 return;
288 };
289
290 for msg in msgs.iter_mut() {
291 if let Some(function_call) = msg.get_mut("function_call").and_then(|v| v.as_object_mut())
292 && let Some(args) = function_call.get_mut("arguments")
293 && let Some(s) = args.as_str()
294 && let Ok(parsed) = serde_json::from_str(s)
295 {
296 *args = parsed;
297 }
298 }
299}
300
301fn inject_reasoning_content_into_messages(messages: &mut serde_json::Value) {
315 let Some(msgs) = messages.as_array_mut() else {
316 return;
317 };
318
319 for msg in msgs.iter_mut() {
320 if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") {
321 continue;
322 }
323
324 let reasoning = match msg.get("reasoning_content") {
325 Some(serde_json::Value::String(s)) if !s.is_empty() => {
326 format!("<think>{}</think>", s)
327 }
328 Some(serde_json::Value::Array(segments)) => {
329 let mut result = String::new();
330 for seg in segments {
331 if let Some(s) = seg.as_str()
332 && !s.is_empty()
333 {
334 result.push_str("<think>");
335 result.push_str(s);
336 result.push_str("</think>");
337 }
338 }
339 if result.is_empty() {
340 continue;
341 }
342 result
343 }
344 _ => continue,
345 };
346
347 match msg.get("content") {
348 Some(serde_json::Value::String(s)) if !s.is_empty() => {
350 msg["content"] = serde_json::Value::String(format!("{}{}", reasoning, s));
351 }
352 None | Some(serde_json::Value::Null) | Some(serde_json::Value::String(_)) => {
353 msg["content"] = serde_json::Value::String(reasoning);
354 }
355 Some(serde_json::Value::Array(_)) => {
357 let think_part = serde_json::json!({
358 "type": "text",
359 "text": reasoning
360 });
361 if let Some(arr) = msg.get_mut("content").and_then(|v| v.as_array_mut()) {
362 arr.insert(0, think_part);
363 }
364 }
365 _ => continue,
367 }
368
369 if let Some(obj) = msg.as_object_mut() {
372 obj.remove("reasoning_content");
373 }
374 }
375}
376
377impl OAIChatLikeRequest for dynamo_protocols::types::CreateChatCompletionRequest {
383 fn model(&self) -> String {
384 self.model.clone()
385 }
386
387 fn messages(&self) -> Value {
388 let messages_json = serde_json::to_value(&self.messages).unwrap();
389 Value::from_serialize(&messages_json)
390 }
391
392 fn typed_messages(&self) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
393 Some(self.messages.as_slice())
394 }
395
396 fn tools(&self) -> Option<Value> {
397 if self.tools.is_none() {
398 None
399 } else {
400 Some(may_be_fix_tool_schema(
401 serde_json::to_value(&self.tools).unwrap(),
402 )?)
403 }
404 }
405
406 fn tool_choice(&self) -> Option<Value> {
407 if self.tool_choice.is_none() {
408 None
409 } else {
410 Some(Value::from_serialize(&self.tool_choice))
411 }
412 }
413
414 fn response_format(&self) -> Option<Value> {
415 self.response_format.as_ref().map(Value::from_serialize)
416 }
417
418 fn reasoning_effort(&self) -> Option<Value> {
419 self.reasoning_effort.as_ref().map(Value::from_serialize)
420 }
421
422 fn should_add_generation_prompt(&self) -> bool {
423 true
425 }
426
427 fn extract_text(&self) -> Option<TextInput> {
428 Some(TextInput::Single(String::new()))
429 }
430
431 fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
432 self.mm_processor_kwargs.as_ref()
433 }
434}
435
436fn merge_message_content(
440 target: serde_json::Value,
441 source: serde_json::Value,
442) -> serde_json::Value {
443 use serde_json::Value;
444 let text_part = |text: String| json!({"type": "text", "text": text});
445 match (target, source) {
446 (Value::String(mut target), Value::String(source)) => {
447 if !target.is_empty() && !source.is_empty() {
448 target.push_str("\n\n");
449 }
450 target.push_str(&source);
451 Value::String(target)
452 }
453 (Value::Array(mut target), Value::Array(source)) => {
454 target.extend(source);
455 Value::Array(target)
456 }
457 (Value::Array(mut target), Value::String(source)) => {
458 if !source.is_empty() {
459 target.push(text_part(source));
460 }
461 Value::Array(target)
462 }
463 (Value::String(target), Value::Array(source)) => {
464 let mut parts = Vec::with_capacity(source.len() + 1);
465 if !target.is_empty() {
466 parts.push(text_part(target));
467 }
468 parts.extend(source);
469 Value::Array(parts)
470 }
471 (Value::Null, source) => source,
472 (target, _) => target,
474 }
475}
476
477fn append_message_content(target: &mut serde_json::Value, source: serde_json::Value) {
479 let Some(target) = target.as_object_mut() else {
480 return;
481 };
482 let merged = merge_message_content(
483 target.remove("content").unwrap_or(serde_json::Value::Null),
484 source,
485 );
486 target.insert("content".to_string(), merged);
487}
488
489fn take_message_content(message: &mut serde_json::Value) -> serde_json::Value {
490 message
491 .get_mut("content")
492 .map(serde_json::Value::take)
493 .unwrap_or(serde_json::Value::Null)
494}
495
496fn normalize_system_messages(messages: &mut serde_json::Value, rules: SystemNormalization) {
500 let serde_json::Value::Array(list) = messages else {
501 return;
502 };
503 let role_is =
504 |m: &serde_json::Value, r: &str| m.get("role").and_then(|v| v.as_str()) == Some(r);
505
506 if rules.demote_nonleading_system {
507 let leading = list.iter().take_while(|m| role_is(m, "system")).count();
510 if leading > 1 {
511 for mut trailing in list.drain(1..leading).collect::<Vec<_>>() {
512 let content = take_message_content(&mut trailing);
513 append_message_content(&mut list[0], content);
514 }
515 }
516
517 let leading = list.iter().take_while(|m| role_is(m, "system")).count();
520 for m in list.iter_mut().skip(leading) {
521 if role_is(m, "system")
522 && let Some(m) = m.as_object_mut()
523 {
524 m.insert("role".to_string(), json!("user"));
525 }
526 }
527 }
528
529 if rules.coalesce_consecutive_users {
530 let mut coalesced: Vec<serde_json::Value> = Vec::with_capacity(list.len());
531 for mut m in list.drain(..) {
532 if role_is(&m, "user") && coalesced.last().is_some_and(|p| role_is(p, "user")) {
533 let content = take_message_content(&mut m);
534 append_message_content(coalesced.last_mut().unwrap(), content);
535 } else {
536 coalesced.push(m);
537 }
538 }
539 *list = coalesced;
540 }
541}
542
543impl OAIPromptFormatter for HfTokenizerConfigJsonFormatter {
544 fn supports_add_generation_prompt(&self) -> bool {
545 self.supports_add_generation_prompt
546 }
547
548 fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
549 let mixins = Value::from_dyn_object(self.mixins.clone());
550
551 let tools = req.tools();
552 let tools = if self.exclude_tools_when_tool_choice_none {
555 match req.tool_choice() {
556 Some(ref tc) if tc.as_str() == Some("none") => None,
557 _ => tools,
558 }
559 } else {
560 tools
561 };
562 let has_tools = tools.as_ref().and_then(|v| v.len()).is_some_and(|l| l > 0);
564 let add_generation_prompt = req.should_add_generation_prompt();
565
566 tracing::trace!(
567 "Rendering prompt with tools: {:?}, add_generation_prompt: {}",
568 has_tools,
569 add_generation_prompt
570 );
571
572 let (
575 template_name,
576 template_handles_tool_calls_args_string,
577 template_handles_reasoning,
578 system_normalization,
579 ) = if has_tools {
580 (
581 "tool_use",
582 self.tool_use_template_handles_tool_calls_arguments_string,
583 self.tool_use_template_handles_reasoning,
584 self.tool_use_system_normalization,
585 )
586 } else {
587 (
588 "default",
589 self.default_template_handles_tool_calls_arguments_string,
590 self.default_template_handles_reasoning,
591 self.default_system_normalization,
592 )
593 };
594
595 let messages_canonical = req.messages();
596 let mut messages_for_template: serde_json::Value =
597 serde_json::to_value(&messages_canonical).unwrap();
598
599 crate::reject_unsupported_partial_assistant(&messages_for_template)?;
600 crate::reject_unsupported_message_tools(&messages_for_template, &[])?;
601
602 if system_normalization.is_required() {
603 normalize_system_messages(&mut messages_for_template, system_normalization);
604 }
605
606 messages_for_template = serde_json::to_value(may_be_fix_msg_content(
607 messages_for_template,
608 self.requires_content_arrays,
609 self.image_placeholder_template,
610 ))
611 .unwrap();
612
613 if !template_handles_tool_calls_args_string {
620 normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
621 }
622 normalize_function_call_arguments_in_messages(&mut messages_for_template);
626
627 if !template_handles_reasoning {
632 inject_reasoning_content_into_messages(&mut messages_for_template);
633 }
634
635 let ctx = context! {
636 messages => messages_for_template,
637 tools => tools,
638 bos_token => self.config.bos_tok(),
639 eos_token => self.config.eos_tok(),
640 unk_token => self.config.unk_tok(),
641 add_generation_prompt => add_generation_prompt,
642 ..mixins
643 };
644
645 let ctx = if let Some(args) = req.chat_template_args() {
647 let extra = Value::from_serialize(args);
648 context! { ..ctx, ..extra }
649 } else {
650 ctx
651 };
652
653 let tmpl: minijinja::Template<'_, '_> = self.env.get_template(template_name)?;
654 Ok(tmpl.render(&ctx)?)
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use dynamo_protocols::types::ChatCompletionRequestMessage as Msg;
662 use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
665 use minijinja::{Environment, context};
666
667 use super::super::tokcfg::ChatTemplate as SysChatTemplate;
670 use super::super::{
671 ContextMixins as SysMixins, HfTokenizerConfigJsonFormatter as SysFormatter,
672 };
673
674 fn formatter_for(template: &str) -> SysFormatter {
675 let ct: SysChatTemplate = serde_json::from_value(json!({
677 "chat_template": template,
678 "bos_token": "<s>",
679 "eos_token": "</s>",
680 "unk_token": "<unk>",
681 }))
682 .unwrap();
683 SysFormatter::new(ct, SysMixins::new(&[])).unwrap()
684 }
685
686 fn formatter_for_templates(default: &str, tool_use: &str) -> SysFormatter {
687 let ct: SysChatTemplate = serde_json::from_value(json!({
688 "chat_template": [
689 {"default": default},
690 {"tool_use": tool_use},
691 ],
692 "bos_token": "<s>",
693 "eos_token": "</s>",
694 "unk_token": "<unk>",
695 }))
696 .unwrap();
697 SysFormatter::new(ct, SysMixins::new(&[])).unwrap()
698 }
699
700 fn try_formatter_for(template: &str) -> Option<SysFormatter> {
701 let ct: SysChatTemplate = serde_json::from_value(json!({
702 "chat_template": template,
703 "bos_token": "<s>",
704 "eos_token": "</s>",
705 "unk_token": "<unk>",
706 }))
707 .ok()?;
708 SysFormatter::new(ct, SysMixins::new(&[])).ok()
709 }
710
711 fn render_shape(f: &SysFormatter, messages: serde_json::Value) -> Result<String> {
712 let req: NvCreateChatCompletionRequest =
713 serde_json::from_value(json!({ "model": "test", "messages": messages })).unwrap();
714 f.render(&req)
715 }
716
717 fn render_shape_with_tools(f: &SysFormatter, messages: serde_json::Value) -> Result<String> {
718 let req: NvCreateChatCompletionRequest = serde_json::from_value(json!({
719 "model": "test",
720 "messages": messages,
721 "tools": [{
722 "type": "function",
723 "function": {"name": "noop", "parameters": {}}
724 }]
725 }))
726 .unwrap();
727 f.render(&req)
728 }
729
730 struct RawMessagesRequest(Value);
731
732 impl OAIChatLikeRequest for RawMessagesRequest {
733 fn model(&self) -> String {
734 "test".to_string()
735 }
736
737 fn messages(&self) -> Value {
738 self.0.clone()
739 }
740
741 fn should_add_generation_prompt(&self) -> bool {
742 true
743 }
744 }
745
746 fn render_raw_shape(f: &SysFormatter, messages: serde_json::Value) -> Result<String> {
747 f.render(&RawMessagesRequest(Value::from_serialize(&messages)))
748 }
749
750 const PERMISSIVE_TMPL: &str = concat!(
751 "{%- for m in messages -%}",
752 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
753 "{%- endfor -%}"
754 );
755
756 #[test]
757 fn jinja_templates_reject_message_level_tools() {
758 let f = formatter_for(PERMISSIVE_TMPL);
759 let error = render_shape(
760 &f,
761 json!([
762 {"role": "system", "tools": [{"name": "lookup", "parameters": {"type": "object"}}]},
763 {"role": "user", "content": "hi"}
764 ]),
765 )
766 .unwrap_err();
767 assert!(matches!(
768 error.downcast_ref::<crate::PromptRenderError>(),
769 Some(crate::PromptRenderError::InvalidRequest(message))
770 if message.contains("message-level `tools`")
771 ));
772
773 let error = render_raw_shape(
774 &f,
775 json!([{
776 "role": "user",
777 "content": "hi",
778 "tools": [{"name": "lookup", "parameters": {"type": "object"}}]
779 }]),
780 )
781 .unwrap_err();
782 assert!(matches!(
783 error.downcast_ref::<crate::PromptRenderError>(),
784 Some(crate::PromptRenderError::InvalidRequest(message))
785 if message.contains("message-level `tools`")
786 ));
787
788 let rendered = render_shape(
789 &f,
790 json!([
791 {"role": "system", "content": "You are helpful.", "tools": []},
792 {"role": "user", "content": "hi"}
793 ]),
794 )
795 .unwrap();
796 assert!(rendered.contains("<|im_start|>system\nYou are helpful.<|im_end|>"));
797 }
798
799 #[test]
800 fn jinja_templates_reject_unsupported_partial_assistant() {
801 let f = formatter_for(PERMISSIVE_TMPL);
802 let error = render_shape(
803 &f,
804 json!([
805 {"role": "user", "content": "Continue"},
806 {"role": "assistant", "content": "prefix", "partial": true}
807 ]),
808 )
809 .unwrap_err();
810 assert!(matches!(
811 error.downcast_ref::<crate::PromptRenderError>(),
812 Some(crate::PromptRenderError::InvalidRequest(message))
813 if message.contains("`partial: true` is not supported")
814 ));
815
816 let rendered = render_shape(
817 &f,
818 json!([{"role": "assistant", "content": "ordinary", "partial": false}]),
819 )
820 .unwrap();
821 assert!(rendered.contains("ordinary"));
822 }
823 const STRICT_LEADING_TMPL: &str = concat!(
825 "{%- for m in messages -%}",
826 "{%- if m.role == 'system' and not loop.first -%}",
827 "{{ raise_exception('System message must be at the beginning.') }}",
828 "{%- endif -%}",
829 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
830 "{%- endfor -%}"
831 );
832 const ALTERNATION_TMPL: &str = concat!(
834 "{%- set ns = namespace(prev='') -%}",
835 "{%- for m in messages -%}",
836 "{%- if m.role == 'user' and ns.prev == 'user' -%}",
837 "{{ raise_exception('Conversation roles must alternate.') }}",
838 "{%- endif -%}",
839 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
840 "{%- set ns.prev = m.role -%}",
841 "{%- endfor -%}"
842 );
843 const STRICT_BOTH_TMPL: &str = concat!(
845 "{%- set ns = namespace(prev='') -%}",
846 "{%- for m in messages -%}",
847 "{%- if m.role == 'system' and not loop.first -%}",
848 "{{ raise_exception('System message must be at the beginning.') }}",
849 "{%- endif -%}",
850 "{%- if m.role == 'user' and ns.prev == 'user' -%}",
851 "{{ raise_exception('Conversation roles must alternate.') }}",
852 "{%- endif -%}",
853 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
854 "{%- set ns.prev = m.role -%}",
855 "{%- endfor -%}"
856 );
857 const DEFAULT_NONE_GATED_TMPL: &str = concat!(
860 "{%- set strict = tools is not none -%}",
861 "{%- for m in messages -%}",
862 "{%- if strict and m.role == 'system' and not loop.first -%}",
863 "{{ raise_exception('System message must be at the beginning.') }}",
864 "{%- endif -%}",
865 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
866 "{%- endfor -%}"
867 );
868 const TOOL_NONEMPTY_GATED_TMPL: &str = concat!(
869 "{%- set strict = tools|length > 0 -%}",
870 "{%- for m in messages -%}",
871 "{%- if strict and m.role == 'system' and not loop.first -%}",
872 "{{ raise_exception('System message must be at the beginning.') }}",
873 "{%- endif -%}",
874 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
875 "{%- endfor -%}"
876 );
877 const STRICT_ARRAY_TMPL: &str = concat!(
879 "{%- for m in messages -%}",
880 "{%- if m.role == 'system' and not loop.first -%}",
881 "{{ raise_exception('System message must be at the beginning.') }}",
882 "{%- endif -%}",
883 "<|im_start|>{{ m.role }}\n",
884 "{%- if m.content is not string -%}",
885 "{%- for part in m.content -%}{{ part.text }}{%- endfor -%}",
886 "{%- endif -%}",
887 "<|im_end|>\n",
888 "{%- endfor -%}"
889 );
890
891 fn claude_shape() -> serde_json::Value {
893 json!([
894 {"role": "system", "content": "You are Claude Code."},
895 {"role": "user", "content": "hello"},
896 {"role": "system", "content": "mid-conversation reminder"},
897 ])
898 }
899
900 fn all_restrictions() -> SystemNormalization {
901 SystemNormalization {
902 demote_nonleading_system: true,
903 coalesce_consecutive_users: true,
904 }
905 }
906
907 #[test]
908 fn permissive_template_is_not_flagged_and_renders_untouched() {
909 let f = formatter_for(PERMISSIVE_TMPL);
910 assert!(!f.default_system_normalization.is_required());
911 assert!(!f.tool_use_system_normalization.is_required());
912 let out = render_shape(&f, claude_shape()).unwrap();
913 assert!(out.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
914 }
915
916 #[test]
917 fn strict_leading_template_demotes_mid_system_but_keeps_user_turns_apart() {
918 let f = formatter_for(STRICT_LEADING_TMPL);
919 assert!(f.default_system_normalization.demote_nonleading_system);
920 assert!(!f.default_system_normalization.coalesce_consecutive_users);
922
923 let out = render_shape(&f, claude_shape()).unwrap();
925 assert_eq!(out.matches("<|im_start|>system").count(), 1);
926 assert!(out.contains("<|im_start|>user\nhello<|im_end|>"));
927 assert!(out.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
928 }
929
930 #[test]
931 fn alternation_template_coalesces_users_but_keeps_mid_system() {
932 let f = formatter_for(ALTERNATION_TMPL);
933 assert!(f.default_system_normalization.coalesce_consecutive_users);
934 assert!(!f.default_system_normalization.demote_nonleading_system);
936
937 let out = render_shape(&f, claude_shape()).unwrap();
938 assert!(out.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
939
940 let out = render_shape(
941 &f,
942 json!([
943 {"role": "system", "content": "s"},
944 {"role": "user", "content": "hello"},
945 {"role": "user", "content": "again"},
946 ]),
947 )
948 .unwrap();
949 assert_eq!(out.matches("<|im_start|>user").count(), 1);
950 assert!(out.contains("<|im_start|>user\nhello\n\nagain<|im_end|>"));
951 }
952
953 #[test]
954 fn strict_both_template_demotes_then_coalesces() {
955 let f = formatter_for(STRICT_BOTH_TMPL);
956 assert!(f.default_system_normalization.demote_nonleading_system);
957 assert!(f.default_system_normalization.coalesce_consecutive_users);
958
959 let out = render_shape(&f, claude_shape()).unwrap();
960 assert_eq!(out.matches("<|im_start|>system").count(), 1);
961 assert!(out.contains("<|im_start|>user\nhello\n\nmid-conversation reminder<|im_end|>"));
962 }
963
964 #[test]
965 fn system_normalization_flag_is_selected_per_template() {
966 let f = formatter_for_templates(PERMISSIVE_TMPL, STRICT_LEADING_TMPL);
967 assert!(!f.default_system_normalization.is_required());
968 assert!(f.tool_use_system_normalization.is_required());
969
970 let no_tools = render_shape(&f, claude_shape()).unwrap();
971 assert!(no_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
972 let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
973 assert_eq!(with_tools.matches("<|im_start|>system").count(), 1);
974 assert!(with_tools.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
975
976 let f = formatter_for_templates(STRICT_LEADING_TMPL, PERMISSIVE_TMPL);
977 assert!(f.default_system_normalization.is_required());
978 assert!(!f.tool_use_system_normalization.is_required());
979 let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
980 assert!(with_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
981 }
982
983 #[test]
984 fn system_normalization_probe_uses_runtime_tools_shape() {
985 let f = formatter_for_templates(DEFAULT_NONE_GATED_TMPL, TOOL_NONEMPTY_GATED_TMPL);
986 assert!(!f.default_system_normalization.is_required());
987 assert!(f.tool_use_system_normalization.is_required());
988
989 let no_tools = render_shape(&f, claude_shape()).unwrap();
990 assert!(no_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
991
992 let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
993 assert_eq!(with_tools.matches("<|im_start|>system").count(), 1);
994 assert!(with_tools.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
995 }
996
997 #[test]
998 fn system_normalization_precedes_required_content_array_conversion() {
999 let f = formatter_for(STRICT_ARRAY_TMPL);
1000 assert!(f.requires_content_arrays);
1001 assert!(f.default_system_normalization.demote_nonleading_system);
1002
1003 let out = render_shape(
1004 &f,
1005 json!([
1006 {"role": "system", "content": "A"},
1007 {"role": "system", "content": "B"},
1008 {"role": "user", "content": "hello"},
1009 ]),
1010 )
1011 .unwrap();
1012 assert!(out.contains("A\n\nB"));
1013 }
1014
1015 #[test]
1016 fn normalize_preserves_multimodal_user_content_and_fields() {
1017 let mut m = json!([
1018 {
1019 "role": "user",
1020 "name": "kept",
1021 "content": [
1022 {"type": "text", "text": "look"},
1023 {"type": "image"},
1024 ],
1025 },
1026 {"role": "system", "content": "remember"},
1027 ]);
1028 normalize_system_messages(&mut m, all_restrictions());
1029 assert_eq!(
1030 m,
1031 json!([{
1032 "role": "user",
1033 "name": "kept",
1034 "content": [
1035 {"type": "text", "text": "look"},
1036 {"type": "image"},
1037 {"type": "text", "text": "remember"},
1038 ],
1039 }])
1040 );
1041 }
1042
1043 #[test]
1046 fn coalesce_preserves_multimodal_content_of_the_merged_turn() {
1047 let mut m = json!([
1048 {"role": "user", "content": "look"},
1049 {"role": "user", "content": [
1050 {"type": "text", "text": "at this"},
1051 {"type": "image_url", "image_url": {"url": "http://img"}},
1052 ]},
1053 ]);
1054 normalize_system_messages(&mut m, all_restrictions());
1055 assert_eq!(
1056 m,
1057 json!([{
1058 "role": "user",
1059 "content": [
1060 {"type": "text", "text": "look"},
1061 {"type": "text", "text": "at this"},
1062 {"type": "image_url", "image_url": {"url": "http://img"}},
1063 ],
1064 }])
1065 );
1066 }
1067
1068 #[test]
1069 fn normalize_merges_leading_run_and_coalesces() {
1070 let mut m = json!([
1071 {"role": "system", "content": "A"},
1072 {"role": "system", "content": "B"},
1073 {"role": "user", "content": "hi"},
1074 {"role": "system", "content": "reminder"},
1075 ]);
1076 normalize_system_messages(&mut m, all_restrictions());
1077 assert_eq!(
1078 m,
1079 json!([
1080 {"role": "system", "content": "A\n\nB"},
1081 {"role": "user", "content": "hi\n\nreminder"},
1082 ])
1083 );
1084 }
1085
1086 #[test]
1089 fn each_restriction_applies_only_its_own_rewrite() {
1090 let shape = json!([
1091 {"role": "system", "content": "A"},
1092 {"role": "system", "content": "B"},
1093 {"role": "user", "content": "hi"},
1094 {"role": "system", "content": "reminder"},
1095 ]);
1096
1097 let mut demote_only = shape.clone();
1098 normalize_system_messages(
1099 &mut demote_only,
1100 SystemNormalization {
1101 demote_nonleading_system: true,
1102 coalesce_consecutive_users: false,
1103 },
1104 );
1105 assert_eq!(
1106 demote_only,
1107 json!([
1108 {"role": "system", "content": "A\n\nB"},
1109 {"role": "user", "content": "hi"},
1110 {"role": "user", "content": "reminder"},
1111 ])
1112 );
1113
1114 let mut coalesce_only = shape.clone();
1115 normalize_system_messages(
1116 &mut coalesce_only,
1117 SystemNormalization {
1118 demote_nonleading_system: false,
1119 coalesce_consecutive_users: true,
1120 },
1121 );
1122 assert_eq!(coalesce_only, shape);
1123 }
1124
1125 #[test]
1126 fn normalize_preserves_array_system_content() {
1127 let mut m = json!([
1128 {"role": "user", "content": "hi"},
1129 {"role": "system", "content": [{"type": "text", "text": "one"},
1130 {"type": "text", "text": "two"}]},
1131 ]);
1132 normalize_system_messages(&mut m, all_restrictions());
1133 assert_eq!(
1134 m,
1135 json!([{"role": "user", "content": [
1136 {"type": "text", "text": "hi"},
1137 {"type": "text", "text": "one"},
1138 {"type": "text", "text": "two"},
1139 ]}])
1140 );
1141 }
1142
1143 #[test]
1152 #[ignore]
1153 fn adaptive_system_corpus_audit() {
1154 let dir =
1155 std::env::var("TEMPLATE_CORPUS").expect("set TEMPLATE_CORPUS to the templates dir");
1156 let manifest: serde_json::Value =
1157 serde_json::from_str(&std::fs::read_to_string(format!("{dir}/manifest.json")).unwrap())
1158 .unwrap();
1159
1160 let sys = |c: &str| json!({"role": "system", "content": c});
1163 let usr = |c: &str| json!({"role": "user", "content": c});
1164 let asst = |c: &str| json!({"role": "assistant", "content": c});
1165 let shapes: Vec<(&str, serde_json::Value)> = vec![
1166 ("turn1", json!([sys("s"), usr("u"), sys("mid")])),
1167 (
1168 "multiturn",
1169 json!([sys("s"), usr("u"), sys("mid"), asst("a"), usr("u2")]),
1170 ),
1171 (
1172 "mid_after_asst",
1173 json!([sys("s"), usr("u"), asst("a"), sys("mid"), usr("u2")]),
1174 ),
1175 ("double_leading", json!([sys("s0"), sys("s1"), usr("u")])),
1176 ("consec_user", json!([sys("s"), usr("u0"), usr("u1")])),
1177 (
1178 "tail_reminder",
1179 json!([
1180 sys("s"),
1181 usr("u"),
1182 asst("a"),
1183 usr("u2"),
1184 sys("mid"),
1185 usr("u3")
1186 ]),
1187 ),
1188 ("leading_only_baseline", json!([sys("s"), usr("u")])),
1189 ];
1190
1191 let mut total = 0usize;
1192 let mut flagged = 0usize;
1193 let mut demote_only = 0usize;
1194 let mut coalesce = 0usize;
1195 let mut failures: Vec<String> = Vec::new();
1196 for (file, meta) in manifest.as_object().unwrap() {
1197 let tmpl = std::fs::read_to_string(format!("{dir}/{file}.jinja")).unwrap();
1198 let model = meta["model"].as_str().unwrap_or(file);
1199 let f = match try_formatter_for(&tmpl) {
1202 Some(f) => f,
1203 None => {
1204 eprintln!("[skip-compile] {model}");
1205 continue;
1206 }
1207 };
1208 if render_shape(&f, json!([sys("s"), usr("u")])).is_err() {
1211 eprintln!("[skip-baseline] {model}");
1212 continue;
1213 }
1214 total += 1;
1215 let rules = f.default_system_normalization;
1216 let flag = rules.is_required();
1217 if flag {
1218 flagged += 1;
1219 }
1220 if rules.demote_nonleading_system {
1221 demote_only += usize::from(!rules.coalesce_consecutive_users);
1222 }
1223 if rules.coalesce_consecutive_users {
1224 coalesce += 1;
1225 }
1226 for (name, shape) in &shapes {
1227 if render_shape(&f, shape.clone()).is_err() {
1228 failures.push(format!("{model} | shape={name} | flag={flag}"));
1229 }
1230 }
1231 if flag {
1232 eprintln!(
1233 "[ok] demote={} coalesce={} {model}",
1234 rules.demote_nonleading_system, rules.coalesce_consecutive_users
1235 );
1236 }
1237 }
1238 eprintln!(
1239 "\naudited {total} templates ({flagged} flagged: {demote_only} demote-only, \
1240 {coalesce} coalescing); {} shape failures",
1241 failures.len()
1242 );
1243 for f in &failures {
1244 eprintln!(" FAIL {f}");
1245 }
1246 assert!(
1247 failures.is_empty(),
1248 "{} template/shape combinations did not render (probe insufficient or normalization insufficient)",
1249 failures.len()
1250 );
1251 }
1252
1253 #[test]
1261 fn test_render_long_conversation_does_not_overflow_stack() {
1262 let handle = std::thread::Builder::new()
1263 .stack_size(2 * 1024 * 1024)
1264 .spawn(|| {
1265 let template_string = concat!(
1266 "{%- set ns = namespace(items=[]) -%}",
1267 "{%- for m in messages -%}",
1268 "{%- set ns.items = ns.items + [m] -%}",
1269 "{%- endfor -%}",
1270 "COUNT={{ ns.items | length }}"
1271 );
1272 let chat_template: ChatTemplate =
1273 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
1274 .unwrap();
1275 let formatter =
1276 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[]))
1277 .unwrap();
1278
1279 let n = 3000;
1280 let messages: Vec<serde_json::Value> = (0..n)
1281 .map(|i| serde_json::json!({"role": "user", "content": format!("turn {i}")}))
1282 .collect();
1283 let request: NvCreateChatCompletionRequest =
1284 serde_json::from_value(serde_json::json!({
1285 "model": "test",
1286 "messages": messages,
1287 }))
1288 .unwrap();
1289
1290 let rendered = formatter.render(&request).unwrap();
1292 assert_eq!(rendered.trim(), format!("COUNT={n}"));
1293 })
1294 .unwrap();
1295 handle.join().unwrap();
1296 }
1297
1298 #[test]
1309 #[ignore]
1310 fn dump_gptoss_tool_prompt() {
1311 use super::tokcfg::ChatTemplate;
1312 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1313
1314 let path = std::env::var("GPTOSS_CHAT_TEMPLATE").expect(
1315 "set GPTOSS_CHAT_TEMPLATE to the tokenizer_config.json, chat_template.jinja, or model dir path",
1316 );
1317 let input_path = std::path::Path::new(&path);
1318 let file_path = if input_path.is_dir() {
1319 input_path.join("tokenizer_config.json")
1321 } else {
1322 input_path.to_path_buf()
1323 };
1324 let raw = std::fs::read_to_string(&file_path).expect("read chat template file");
1325 let template_string: String = match serde_json::from_str::<serde_json::Value>(&raw) {
1332 Ok(v) if v.get("chat_template").is_some() => v["chat_template"]
1333 .as_str()
1334 .expect("chat_template field must be a string")
1335 .to_string(),
1336 _ => {
1337 let sibling = std::path::Path::new(&path)
1338 .parent()
1339 .map(|d| d.join("chat_template.jinja"));
1340 match sibling {
1341 Some(p) if p.exists() => {
1342 eprintln!(
1343 "[info] {path} had no chat_template field; using {}",
1344 p.display()
1345 );
1346 std::fs::read_to_string(&p).expect("read sibling chat_template.jinja")
1347 }
1348 _ => raw,
1349 }
1350 }
1351 };
1352
1353 assert!(
1356 template_string.contains("{%") || template_string.contains("{{"),
1357 "resolved template has no Jinja tags — GPTOSS_CHAT_TEMPLATE ({path}) is probably \
1358 tokenizer_config.json with no chat_template field and no sibling chat_template.jinja. \
1359 Point it at the chat_template.jinja file."
1360 );
1361
1362 let chat_template: ChatTemplate =
1363 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
1364 .unwrap();
1365
1366 let formatter =
1367 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1368
1369 let request: NvCreateChatCompletionRequest = serde_json::from_str(
1371 r#"{
1372 "model": "openai/gpt-oss-120b",
1373 "messages": [{"role":"user","content":"Search the repo for the string \"countHook\"."}],
1374 "tools": [
1375 {"type":"function","function":{"name":"grep","description":"search files","parameters":{"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}}},
1376 {"type":"function","function":{"name":"read","description":"read a file","parameters":{"type":"object","properties":{"filePath":{"type":"string"}},"required":["filePath"]}}}
1377 ]
1378 }"#,
1379 )
1380 .unwrap();
1381
1382 let rendered = formatter.render(&request).unwrap();
1383 eprintln!("================ RENDERED gpt-oss PROMPT (tools declared) ================");
1384 eprintln!("{rendered}");
1385 eprintln!("================ END RENDERED PROMPT ================");
1386 eprintln!("[diagnostics] does the rendered prompt contain…");
1387 for needle in [
1388 "commentary",
1389 "Calls to these tools",
1390 "functions",
1391 "# Tools",
1392 "<|channel|>",
1393 "constrain",
1394 "analysis",
1395 ] {
1396 eprintln!(
1397 " {:>22}: {}",
1398 format!("{needle:?}"),
1399 rendered.contains(needle)
1400 );
1401 }
1402 }
1403
1404 #[test]
1406 fn test_convert_media_url_to_placeholder_single_type() {
1407 let content_array = vec![
1408 serde_json::json!({"type": "text", "text": "Check this image:"}),
1409 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1410 serde_json::json!({"type": "text", "text": "What do you see?"}),
1411 ];
1412
1413 let conversions = &[("image_url", "image")];
1414 let result = convert_media_url_to_placeholder(&content_array, conversions);
1415
1416 assert_eq!(result.len(), 3);
1417 assert_eq!(result[0]["type"], "text");
1419 assert_eq!(result[0]["text"], "Check this image:");
1420 assert_eq!(result[1]["type"], "image");
1422 assert!(result[1].get("image_url").is_none());
1423 assert_eq!(result[2]["type"], "text");
1425 assert_eq!(result[2]["text"], "What do you see?");
1426 }
1427
1428 #[test]
1430 fn test_convert_media_url_to_placeholder_multiple_same_type() {
1431 let content_array = vec![
1432 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}}),
1433 serde_json::json!({"type": "text", "text": "vs"}),
1434 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}),
1435 ];
1436
1437 let conversions = &[("image_url", "image")];
1438 let result = convert_media_url_to_placeholder(&content_array, conversions);
1439
1440 assert_eq!(result.len(), 3);
1441 assert_eq!(result[0]["type"], "image");
1442 assert_eq!(result[1]["type"], "text");
1443 assert_eq!(result[2]["type"], "image");
1444 }
1445
1446 #[test]
1448 fn test_convert_media_url_to_placeholder_selective_conversion() {
1449 let content_array = vec![
1450 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1451 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1452 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1453 ];
1454
1455 let conversions = &[("image_url", "image")];
1457 let result = convert_media_url_to_placeholder(&content_array, conversions);
1458
1459 assert_eq!(result.len(), 3);
1460 assert_eq!(result[0]["type"], "audio_url");
1462 assert!(result[0].get("audio_url").is_some());
1463 assert_eq!(result[1]["type"], "video_url");
1464 assert!(result[1].get("video_url").is_some());
1465 assert_eq!(result[2]["type"], "image");
1467 assert!(result[2].get("image_url").is_none());
1468 }
1469
1470 #[test]
1472 fn test_convert_media_url_to_placeholder_multiple_types() {
1473 let content_array = vec![
1474 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1475 serde_json::json!({"type": "text", "text": "and listen to"}),
1476 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1477 serde_json::json!({"type": "text", "text": "and watch"}),
1478 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1479 ];
1480
1481 let conversions = &[
1483 ("image_url", "image"),
1484 ("audio_url", "audio"),
1485 ("video_url", "video"),
1486 ];
1487 let result = convert_media_url_to_placeholder(&content_array, conversions);
1488
1489 assert_eq!(result.len(), 5);
1490 assert_eq!(result[0]["type"], "image");
1491 assert!(result[0].get("image_url").is_none());
1492 assert_eq!(result[1]["type"], "text");
1493 assert_eq!(result[2]["type"], "audio");
1494 assert!(result[2].get("audio_url").is_none());
1495 assert_eq!(result[3]["type"], "text");
1496 assert_eq!(result[4]["type"], "video");
1497 assert!(result[4].get("video_url").is_none());
1498 }
1499
1500 #[test]
1502 fn test_convert_media_url_to_placeholder_no_conversions() {
1503 let content_array = vec![
1504 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1505 serde_json::json!({"type": "text", "text": "hello"}),
1506 ];
1507
1508 let conversions: &[(&str, &str)] = &[];
1509 let result = convert_media_url_to_placeholder(&content_array, conversions);
1510
1511 assert_eq!(result.len(), 2);
1512 assert_eq!(result[0]["type"], "image_url");
1514 assert!(result[0].get("image_url").is_some());
1515 assert_eq!(result[1]["type"], "text");
1516 }
1517
1518 #[test]
1521 fn test_default_media_type_conversions_only_converts_image_url() {
1522 let content_array = vec![
1523 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1524 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1525 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1526 serde_json::json!({"type": "text", "text": "hello"}),
1527 ];
1528
1529 let result =
1531 convert_media_url_to_placeholder(&content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
1532
1533 assert_eq!(result.len(), 4);
1534
1535 assert_eq!(result[0]["type"], "image");
1537 assert!(result[0].get("image_url").is_none());
1538
1539 assert_eq!(result[1]["type"], "video");
1541 assert!(result[1].get("video_url").is_none());
1542
1543 assert_eq!(result[2]["type"], "audio");
1545 assert!(result[2].get("audio_url").is_none());
1546
1547 assert_eq!(result[3]["type"], "text");
1549 assert_eq!(result[3]["text"], "hello");
1550 }
1551
1552 #[test]
1553 fn test_may_be_fix_tool_schema_missing_type_and_properties() {
1554 let json_str = r#"{
1555 "model": "gpt-4o",
1556 "messages": [],
1557 "tools": [
1558 {
1559 "type": "function",
1560 "function": {
1561 "name": "get_weather",
1562 "description": "Get the current weather in a given location",
1563 "parameters": {},
1564 "strict": null
1565 }
1566 }
1567 ]
1568 }"#;
1569
1570 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1571 let tools = serde_json::to_value(request.tools()).unwrap();
1572
1573 assert!(tools[0]["function"]["parameters"]["type"] == "object");
1574 assert!(
1575 tools[0]["function"]["parameters"]["properties"]
1576 == serde_json::Value::Object(Default::default())
1577 );
1578 }
1579
1580 #[test]
1581 fn test_may_be_fix_tool_schema_missing_type() {
1582 let json_str = r#"{
1583 "model": "gpt-4o",
1584 "messages": [],
1585 "tools": [
1586 {
1587 "type": "function",
1588 "function": {
1589 "name": "get_weather",
1590 "description": "Get the current weather in a given location",
1591 "parameters": {
1592 "properties": {
1593 "location": {
1594 "type": "string",
1595 "description": "City and state, e.g., 'San Francisco, CA'"
1596 }
1597 }
1598 },
1599 "strict": null
1600 }
1601 }
1602 ]
1603 }"#;
1604 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1605
1606 let tools = serde_json::to_value(request.tools()).unwrap();
1607
1608 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
1609
1610 let mut expected_properties = serde_json::Map::new();
1611 let mut location = serde_json::Map::new();
1612 location.insert(
1613 "type".to_string(),
1614 serde_json::Value::String("string".to_string()),
1615 );
1616 location.insert(
1617 "description".to_string(),
1618 serde_json::Value::String("City and state, e.g., 'San Francisco, CA'".to_string()),
1619 );
1620 expected_properties.insert("location".to_string(), serde_json::Value::Object(location));
1621
1622 assert_eq!(
1623 tools[0]["function"]["parameters"]["properties"],
1624 serde_json::Value::Object(expected_properties)
1625 );
1626 }
1627
1628 #[test]
1629 fn test_may_be_fix_tool_schema_missing_properties() {
1630 let json_str = r#"{
1631 "model": "gpt-4o",
1632 "messages": [],
1633 "tools": [
1634 {
1635 "type": "function",
1636 "function": {
1637 "name": "get_weather",
1638 "description": "Get the current weather in a given location",
1639 "parameters": {"type": "object"},
1640 "strict": null
1641 }
1642 }
1643 ]
1644 }"#;
1645
1646 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1647 let tools = serde_json::to_value(request.tools()).unwrap();
1648
1649 assert_eq!(
1650 tools[0]["function"]["parameters"]["properties"],
1651 serde_json::Value::Object(Default::default())
1652 );
1653 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
1654 }
1655
1656 #[test]
1657 fn test_may_be_fix_tool_schema_missing_description() {
1658 let json_str = r#"{
1662 "model": "gpt-4o",
1663 "messages": [],
1664 "tools": [
1665 {
1666 "type": "function",
1667 "function": {
1668 "name": "noop",
1669 "parameters": {
1670 "type": "object",
1671 "properties": { "x": { "type": "string" } },
1672 "required": ["x"],
1673 "additionalProperties": false
1674 },
1675 "strict": null
1676 }
1677 }
1678 ]
1679 }"#;
1680
1681 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1682 let tools = serde_json::to_value(request.tools()).unwrap();
1683
1684 assert_eq!(
1685 tools[0]["function"]["description"],
1686 serde_json::Value::String(String::new())
1687 );
1688 }
1689
1690 #[test]
1691 fn test_may_be_fix_tool_schema_null_description() {
1692 let json_str = r#"{
1694 "model": "gpt-4o",
1695 "messages": [],
1696 "tools": [
1697 {
1698 "type": "function",
1699 "function": {
1700 "name": "noop",
1701 "description": null,
1702 "parameters": {"type": "object", "properties": {}},
1703 "strict": null
1704 }
1705 }
1706 ]
1707 }"#;
1708
1709 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1710 let tools = serde_json::to_value(request.tools()).unwrap();
1711
1712 assert_eq!(
1713 tools[0]["function"]["description"],
1714 serde_json::Value::String(String::new())
1715 );
1716 }
1717
1718 #[test]
1719 fn test_may_be_fix_tool_schema_preserves_description() {
1720 let json_str = r#"{
1722 "model": "gpt-4o",
1723 "messages": [],
1724 "tools": [
1725 {
1726 "type": "function",
1727 "function": {
1728 "name": "get_weather",
1729 "description": "Get the current weather in a given location",
1730 "parameters": {"type": "object", "properties": {}},
1731 "strict": null
1732 }
1733 }
1734 ]
1735 }"#;
1736
1737 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1738 let tools = serde_json::to_value(request.tools()).unwrap();
1739
1740 assert_eq!(
1741 tools[0]["function"]["description"],
1742 "Get the current weather in a given location"
1743 );
1744 }
1745
1746 #[test]
1748 fn test_may_be_fix_msg_content_user_multipart() {
1749 let json_str = r#"{
1750 "model": "gpt-4o",
1751 "messages": [
1752 {
1753 "role": "user",
1754 "content": [
1755 {"type": "text", "text": "part 1"},
1756 {"type": "text", "text": "part 2"}
1757 ]
1758 }
1759 ]
1760 }"#;
1761
1762 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1763 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1764
1765 let messages =
1767 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1768
1769 assert_eq!(
1771 messages[0]["content"],
1772 serde_json::Value::String("part 1\npart 2".to_string())
1773 );
1774 }
1775
1776 #[test]
1779 fn test_may_be_fix_msg_content_mixed_messages() {
1780 let json_str = r#"{
1781 "model": "gpt-4o",
1782 "messages": [
1783 {
1784 "role": "system",
1785 "content": "You are a helpful assistant"
1786 },
1787 {
1788 "role": "user",
1789 "content": [
1790 {"type": "text", "text": "Hello"},
1791 {"type": "text", "text": "World"}
1792 ]
1793 },
1794 {
1795 "role": "assistant",
1796 "content": "Hi there!"
1797 },
1798 {
1799 "role": "user",
1800 "content": [
1801 {"type": "text", "text": "Another"},
1802 {"type": "text", "text": "multi-part"},
1803 {"type": "text", "text": "message"}
1804 ]
1805 }
1806 ]
1807 }"#;
1808
1809 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1810 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1811
1812 let messages =
1814 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1815
1816 assert_eq!(
1818 messages[0]["content"],
1819 serde_json::Value::String("You are a helpful assistant".to_string())
1820 );
1821
1822 assert_eq!(
1824 messages[1]["content"],
1825 serde_json::Value::String("Hello\nWorld".to_string())
1826 );
1827
1828 assert_eq!(
1830 messages[2]["content"],
1831 serde_json::Value::String("Hi there!".to_string())
1832 );
1833
1834 assert_eq!(
1836 messages[3]["content"],
1837 serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
1838 );
1839 }
1840
1841 #[test]
1843 fn test_may_be_fix_msg_content_empty_array() {
1844 let json_str = r#"{
1845 "model": "gpt-4o",
1846 "messages": [
1847 {
1848 "role": "user",
1849 "content": []
1850 }
1851 ]
1852 }"#;
1853
1854 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1855 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1856
1857 let messages =
1859 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1860
1861 assert!(messages[0]["content"].is_array());
1863 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1864 }
1865
1866 #[test]
1873 fn test_may_be_fix_msg_content_empty_array_with_placeholder_template() {
1874 let json_str = r#"{
1875 "model": "phi-3-vision",
1876 "messages": [
1877 {
1878 "role": "user",
1879 "content": []
1880 }
1881 ]
1882 }"#;
1883
1884 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1885 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1886
1887 let messages = serde_json::to_value(may_be_fix_msg_content(
1890 messages_raw,
1891 false,
1892 Some("<|image_{n}|>"),
1893 ))
1894 .unwrap();
1895
1896 assert!(
1897 messages[0]["content"].is_array(),
1898 "empty array should be preserved as `[]`, not flattened to `\"\"`"
1899 );
1900 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1901 }
1902
1903 #[test]
1905 fn test_may_be_fix_msg_content_single_text() {
1906 let json_str = r#"{
1907 "model": "gpt-4o",
1908 "messages": [
1909 {
1910 "role": "user",
1911 "content": "Simple text message"
1912 }
1913 ]
1914 }"#;
1915
1916 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1917 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1918
1919 let messages =
1921 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1922
1923 assert_eq!(
1925 messages[0]["content"],
1926 serde_json::Value::String("Simple text message".to_string())
1927 );
1928 }
1929
1930 #[test]
1933 fn test_may_be_fix_msg_content_mixed_types() {
1934 let json_str = r#"{
1935 "model": "gpt-4o",
1936 "messages": [
1937 {
1938 "role": "user",
1939 "content": [
1940 {"type": "text", "text": "Check this image:"},
1941 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
1942 {"type": "text", "text": "What do you see?"}
1943 ]
1944 }
1945 ]
1946 }"#;
1947
1948 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1949 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1950
1951 let messages =
1953 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1954
1955 assert!(messages[0]["content"].is_array());
1958 let content_array = messages[0]["content"].as_array().unwrap();
1959 assert_eq!(content_array.len(), 3);
1960 assert_eq!(content_array[0]["type"], "text");
1961 assert_eq!(content_array[1]["type"], "image");
1962 assert!(content_array[1].get("image_url").is_none());
1963 assert_eq!(content_array[2]["type"], "text");
1964 }
1965
1966 #[test]
1972 fn test_may_be_fix_msg_content_flattens_phi3_style() {
1973 let json_str = r#"{
1974 "model": "phi-3-vision",
1975 "messages": [
1976 {
1977 "role": "user",
1978 "content": [
1979 {"type": "text", "text": "First "},
1980 {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}},
1981 {"type": "text", "text": " then "},
1982 {"type": "image_url", "image_url": {"url": "https://example.com/b.jpg"}},
1983 {"type": "text", "text": "?"}
1984 ]
1985 }
1986 ]
1987 }"#;
1988 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1989 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1990
1991 let messages = serde_json::to_value(may_be_fix_msg_content(
1992 messages_raw,
1993 false,
1994 Some("<|image_{n}|>"),
1995 ))
1996 .unwrap();
1997
1998 let content = messages[0]["content"].as_str().expect("content flattened");
1999 assert_eq!(content, "First <|image_1|> then <|image_2|>?");
2000 }
2001
2002 #[test]
2004 fn test_may_be_fix_msg_content_flattens_llava_style() {
2005 let json_str = r#"{
2006 "model": "llava-1.5-7b-hf",
2007 "messages": [
2008 {
2009 "role": "user",
2010 "content": [
2011 {"type": "text", "text": "Describe: "},
2012 {"type": "image_url", "image_url": {"url": "https://example.com/x.jpg"}}
2013 ]
2014 }
2015 ]
2016 }"#;
2017 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2018 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2019
2020 let messages =
2021 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some("<image>")))
2022 .unwrap();
2023
2024 let content = messages[0]["content"].as_str().expect("content flattened");
2025 assert_eq!(content, "Describe: <image>");
2026 }
2027
2028 #[test]
2034 fn test_may_be_fix_msg_content_flattens_empty_placeholder() {
2035 let json_str = r#"{
2036 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
2037 "messages": [
2038 {
2039 "role": "user",
2040 "content": [
2041 {"type": "text", "text": "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"},
2042 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
2043 ]
2044 }
2045 ]
2046 }"#;
2047 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2048 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2049
2050 let messages =
2051 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some(""))).unwrap();
2052
2053 let content = messages[0]["content"].as_str().expect("content flattened");
2054 assert_eq!(
2055 content,
2056 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
2057 );
2058 }
2059
2060 #[test]
2067 fn test_render_nemotron_parse_passthrough() {
2068 use super::super::tokcfg::ChatTemplate;
2069 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2070
2071 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2072 "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}"
2073 }))
2074 .unwrap();
2075 let formatter =
2076 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2077
2078 for prompt in [
2079 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>",
2080 "</s><s><predict_bbox><predict_classes><output_markdown><predict_text_in_pic>",
2081 ] {
2082 let request: NvCreateChatCompletionRequest =
2083 serde_json::from_value(serde_json::json!({
2084 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
2085 "messages": [{
2086 "role": "user",
2087 "content": [
2088 {"type": "text", "text": prompt},
2089 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
2090 ]
2091 }]
2092 }))
2093 .unwrap();
2094
2095 let rendered = formatter.render(&request).unwrap();
2096 assert_eq!(
2097 rendered, prompt,
2098 "rendered prompt must be the control tokens only, with no JSON-serialized image array"
2099 );
2100 }
2101 }
2102
2103 #[test]
2106 fn test_may_be_fix_msg_content_non_text_only() {
2107 let json_str = r#"{
2108 "model": "gpt-4o",
2109 "messages": [
2110 {
2111 "role": "user",
2112 "content": [
2113 {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}},
2114 {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}
2115 ]
2116 }
2117 ]
2118 }"#;
2119
2120 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2121 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2122
2123 let messages =
2125 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2126
2127 assert!(messages[0]["content"].is_array());
2129 let content_array = messages[0]["content"].as_array().unwrap();
2130 assert_eq!(content_array.len(), 2);
2131 assert_eq!(content_array[0]["type"], "image");
2132 assert_eq!(content_array[1]["type"], "image");
2133 }
2134
2135 #[test]
2136 fn test_none_tools_safe_for_all_templates() {
2137 use super::tokcfg::ChatTemplate;
2138 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2139
2140 let length_template = r#"
2144{%- if tools is iterable and tools | length > 0 %}
2145Tools available: {{ tools | length }}
2146{%- else %}
2147No tools
2148{%- endif %}
2149"#;
2150
2151 let no_tool_template = r#"
2154{%- if tools is not none %}
2155TOOL MODE
2156{%- else %}
2157NORMAL MODE
2158{%- endif %}
2159"#;
2160
2161 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2162 "chat_template": [
2163 {"safe_length": length_template},
2164 {"no_tool": no_tool_template}
2165 ]
2166 }))
2167 .unwrap();
2168
2169 let formatter =
2170 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2171
2172 let ctx = context! { tools => Option::<Value>::None };
2173
2174 let result1 = formatter
2175 .env
2176 .get_template("safe_length")
2177 .unwrap()
2178 .render(&ctx);
2179 println!("Safe length template with no tools => None: {:?}", result1);
2180 assert!(
2181 result1.is_ok(),
2182 "Jinja template with and conditional and length filter should handle None: {:?}",
2183 result1
2184 );
2185 assert!(
2186 result1.unwrap().contains("No tools"),
2187 "Should show 'No tools'"
2188 );
2189
2190 let result2 = formatter.env.get_template("no_tool").unwrap().render(&ctx);
2191 println!("Default template with no tools => None: {:?}", result2);
2192 assert!(
2193 result2.is_ok(),
2194 "Jinja template with if tools is not none conditional should handle None: {:?}",
2195 result2
2196 );
2197 assert!(result2.unwrap().contains("NORMAL MODE"));
2198 }
2199
2200 #[test]
2202 fn test_may_be_fix_msg_content_multiple_content_types() {
2203 let json_str = r#"{
2205 "model": "gpt-4o",
2206 "messages": [
2207 {
2208 "role": "user",
2209 "content": [
2210 {"type": "text", "text": "Listen to this:"},
2211 {"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}},
2212 {"type": "text", "text": "And look at:"},
2213 {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
2214 {"type": "text", "text": "What do you think?"}
2215 ]
2216 }
2217 ]
2218 }"#;
2219
2220 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2221 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2222 let messages =
2223 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2224
2225 assert!(messages[0]["content"].is_array());
2227 let content_array = messages[0]["content"].as_array().unwrap();
2228 assert_eq!(content_array.len(), 5);
2229 assert_eq!(content_array[0]["type"], "text");
2230 assert_eq!(content_array[1]["type"], "audio");
2231 assert_eq!(content_array[2]["type"], "text");
2232 assert_eq!(content_array[3]["type"], "image");
2233 assert_eq!(content_array[4]["type"], "text");
2234
2235 let json_str = r#"{
2237 "model": "gpt-4o",
2238 "messages": [
2239 {
2240 "role": "user",
2241 "content": [
2242 {"type": "text", "text": "Check this:"},
2243 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
2244 {"type": "text", "text": "Interesting?"}
2245 ]
2246 }
2247 ]
2248 }"#;
2249
2250 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2251 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2252 let messages =
2253 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2254
2255 assert!(messages[0]["content"].is_array());
2257 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
2258 }
2259
2260 #[test]
2261 fn test_normalize_tool_arguments_tojson() {
2262 let tmpl = r#"{{ messages[0].tool_calls[0].function.arguments | tojson }}"#;
2263
2264 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2266 "role": "assistant",
2267 "tool_calls": [{
2268 "type": "function",
2269 "function": {
2270 "name": "get_current_weather",
2271 "arguments": "{\"format\":\"celsius\",\"location\":\"San Francisco, CA\"}"
2272 }
2273 }]
2274 })]);
2275
2276 normalize_tool_calls_arguments_in_messages(&mut messages);
2277
2278 let mut env = Environment::new();
2279 env.add_filter("tojson", super::super::tokcfg::tojson);
2280 env.add_template("t", tmpl).unwrap();
2281 let out = env
2282 .get_template("t")
2283 .unwrap()
2284 .render(context! { messages => messages.as_array().unwrap() })
2285 .unwrap();
2286
2287 assert_eq!(
2290 out,
2291 r#"{"format": "celsius", "location": "San Francisco, CA"}"#
2292 );
2293 }
2294
2295 #[test]
2296 fn test_normalize_tool_arguments_items_loop() {
2297 let tmpl = r#"{% for k, v in messages[0].tool_calls[0].function.arguments|items %}{{k}}={{v}};{% endfor %}"#;
2298
2299 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2300 "role": "assistant",
2301 "tool_calls": [{
2302 "type": "function",
2303 "function": {
2304 "name": "f",
2305 "arguments": "{\"a\":1,\"b\":\"x\"}"
2306 }
2307 }]
2308 })]);
2309
2310 normalize_tool_calls_arguments_in_messages(&mut messages);
2311
2312 let mut env = Environment::new();
2313 env.add_template("t", tmpl).unwrap();
2314 let out = env
2315 .get_template("t")
2316 .unwrap()
2317 .render(context! { messages => messages.as_array().unwrap() })
2318 .unwrap();
2319
2320 assert!(out == "a=1;b=x;" || out == "b=x;a=1;");
2321 }
2322
2323 #[test]
2324 fn test_normalize_tool_arguments_legacy_function_call() {
2325 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2327 "role": "assistant",
2328 "function_call": {
2329 "name": "get_weather",
2330 "arguments": "{\"location\":\"NYC\"}"
2331 }
2332 })]);
2333
2334 normalize_function_call_arguments_in_messages(&mut messages);
2335
2336 assert_eq!(
2337 messages[0]["function_call"]["arguments"],
2338 serde_json::json!({"location": "NYC"})
2339 );
2340 }
2341
2342 #[test]
2343 fn test_normalize_tool_arguments_malformed_json_passthrough() {
2344 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2346 "role": "assistant",
2347 "tool_calls": [{
2348 "type": "function",
2349 "function": {
2350 "name": "f",
2351 "arguments": "not valid json at all"
2352 }
2353 }]
2354 })]);
2355
2356 normalize_tool_calls_arguments_in_messages(&mut messages);
2357
2358 assert_eq!(
2359 messages[0]["tool_calls"][0]["function"]["arguments"],
2360 serde_json::Value::String("not valid json at all".to_string())
2361 );
2362 }
2363
2364 #[test]
2365 fn test_normalize_tool_arguments_with_multimodal_content() {
2366 let json_str = r#"{
2367 "model": "gpt-4o",
2368 "messages": [
2369 {
2370 "role": "user",
2371 "content": [
2372 {"type": "text", "text": "Check this:"},
2373 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
2374 {"type": "text", "text": "Interesting?"}
2375 ]
2376 },
2377 {
2378 "role": "assistant",
2379 "tool_calls": [{
2380 "id": "call_123",
2381 "type": "function",
2382 "function": {
2383 "name": "analyze_video",
2384 "arguments": "{\"url\":\"https://example.com/vid.mp4\",\"format\":\"mp4\"}"
2385 }
2386 }]
2387 }
2388 ]
2389 }"#;
2390
2391 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2392 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2393
2394 let mut messages =
2396 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2397
2398 normalize_tool_calls_arguments_in_messages(&mut messages);
2399
2400 assert!(messages[0]["content"].is_array());
2402 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
2403
2404 assert!(messages[1]["tool_calls"][0]["function"]["arguments"].is_object());
2406 assert_eq!(
2407 messages[1]["tool_calls"][0]["function"]["arguments"]["url"],
2408 "https://example.com/vid.mp4"
2409 );
2410 }
2411
2412 #[test]
2414 fn test_may_be_fix_msg_content_string_to_array() {
2415 let json_str = r#"{
2416 "model": "gpt-4o",
2417 "messages": [
2418 {
2419 "role": "user",
2420 "content": "Hello, how are you?"
2421 }
2422 ]
2423 }"#;
2424
2425 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2426 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2427
2428 let messages =
2430 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
2431
2432 assert!(messages[0]["content"].is_array());
2434 let content_array = messages[0]["content"].as_array().unwrap();
2435 assert_eq!(content_array.len(), 1);
2436 assert_eq!(content_array[0]["type"], "text");
2437 assert_eq!(content_array[0]["text"], "Hello, how are you?");
2438 }
2439
2440 #[test]
2442 fn test_may_be_fix_msg_content_array_preserved_with_multimodal() {
2443 let json_str = r#"{
2444 "model": "gpt-4o",
2445 "messages": [
2446 {
2447 "role": "user",
2448 "content": [
2449 {"type": "text", "text": "part 1"},
2450 {"type": "text", "text": "part 2"}
2451 ]
2452 }
2453 ]
2454 }"#;
2455
2456 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2457 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2458
2459 let messages =
2461 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
2462
2463 assert!(messages[0]["content"].is_array());
2465 let content_array = messages[0]["content"].as_array().unwrap();
2466 assert_eq!(content_array.len(), 2);
2467 assert_eq!(content_array[0]["text"], "part 1");
2468 assert_eq!(content_array[1]["text"], "part 2");
2469 }
2470
2471 fn user() -> Msg {
2472 Msg::User(Default::default())
2473 }
2474 fn tool() -> Msg {
2475 Msg::Tool(Default::default())
2476 }
2477
2478 fn dummy_state(messages: Vec<Msg>) -> NvCreateChatCompletionRequest {
2479 let json = serde_json::json!({
2480 "model": "test-model",
2481 "messages": messages
2482 });
2483 serde_json::from_value(json).unwrap()
2484 }
2485
2486 #[test]
2487 fn add_after_user() {
2488 let s = dummy_state(vec![user()]);
2489 assert!(s.should_add_generation_prompt());
2490 }
2491
2492 #[test]
2493 fn add_after_tool() {
2494 let s = dummy_state(vec![tool()]);
2495 assert!(s.should_add_generation_prompt());
2496 }
2497
2498 #[test]
2499 fn add_when_empty() {
2500 let s = dummy_state(vec![]);
2501 assert!(s.should_add_generation_prompt());
2502 }
2503
2504 fn tool_aware_formatter(
2506 exclude_tools_when_tool_choice_none: bool,
2507 ) -> HfTokenizerConfigJsonFormatter {
2508 let template = r#"
2509{%- if tools is iterable and tools | length > 0 %}
2510TOOL_MODE tools={{ tools | length }}
2511{%- else %}
2512NORMAL_MODE
2513{%- endif %}
2514{{ messages[0].content }}"#;
2515
2516 let chat_template: super::tokcfg::ChatTemplate =
2517 serde_json::from_value(serde_json::json!({ "chat_template": template })).unwrap();
2518
2519 HfTokenizerConfigJsonFormatter::with_options(
2520 chat_template,
2521 ContextMixins::new(&[]),
2522 exclude_tools_when_tool_choice_none,
2523 )
2524 .unwrap()
2525 }
2526
2527 fn gemma4_tool_template_for_tests() -> &'static str {
2528 r#"
2529{{ bos_token }}
2530{%- set loop_messages = messages -%}
2531{%- set ns_turn = namespace(last_user_idx=-1) -%}
2532{%- for i in range(loop_messages | length) -%}
2533 {%- if loop_messages[i]['role'] == 'user' -%}
2534 {%- set ns_turn.last_user_idx = i -%}
2535 {%- endif -%}
2536{%- endfor -%}
2537{%- for message in loop_messages -%}
2538 {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
2539 {{- '<|turn>' + role + '\n' }}
2540
2541 {%- if message.get('reasoning') and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
2542 {{- '<|channel>thought\n' + message['reasoning'] + '\n<channel|>'}}
2543 {%- endif -%}
2544
2545 {%- if message['tool_calls'] -%}
2546 {%- for tool_call in message['tool_calls'] -%}
2547 {%- set function = tool_call['function'] -%}
2548 {{- '<|tool_call>call:' + function['name'] + '{' -}}
2549 {%- if function['arguments'] is mapping -%}
2550 {%- set ns_args = namespace(found_first=false) -%}
2551 {%- for key, value in function['arguments'] | dictsort -%}
2552 {%- if ns_args.found_first %},{% endif -%}
2553 {%- set ns_args.found_first = true -%}
2554 {{- key -}}:{{- value -}}
2555 {%- endfor -%}
2556 {%- elif function['arguments'] is string -%}
2557 {{- function['arguments'] -}}
2558 {%- endif -%}
2559 {{- '}<tool_call|>' -}}
2560 {%- endfor -%}
2561 {%- endif -%}
2562
2563 {%- if message['content'] is string -%}
2564 {{- message['content'] -}}
2565 {%- endif -%}
2566 {{- '<turn|>\n' -}}
2567{%- endfor -%}
2568"#
2569 }
2570
2571 fn make_gemma4_tool_formatter_for_tests() -> HfTokenizerConfigJsonFormatter {
2572 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2573 "chat_template": gemma4_tool_template_for_tests()
2574 }))
2575 .unwrap();
2576 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2577 }
2578
2579 fn request_with_tool_choice(tool_choice: &str) -> NvCreateChatCompletionRequest {
2581 serde_json::from_value(serde_json::json!({
2582 "model": "test",
2583 "messages": [{"role": "user", "content": "hello"}],
2584 "tools": [{
2585 "type": "function",
2586 "function": {
2587 "name": "get_weather",
2588 "description": "Get weather",
2589 "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}
2590 }
2591 }],
2592 "tool_choice": tool_choice
2593 }))
2594 .unwrap()
2595 }
2596
2597 #[test]
2598 fn test_exclude_tools_strips_when_tool_choice_none() {
2599 let formatter = tool_aware_formatter(true);
2600 let request = request_with_tool_choice("none");
2601 let result = formatter.render(&request).unwrap();
2602 assert!(
2603 result.contains("NORMAL_MODE"),
2604 "With exclude_tools=true and tool_choice=none, tools should be stripped. Got: {}",
2605 result
2606 );
2607 }
2608
2609 #[test]
2610 fn test_exclude_tools_keeps_when_tool_choice_auto() {
2611 let formatter = tool_aware_formatter(true);
2612 let request = request_with_tool_choice("auto");
2613 let result = formatter.render(&request).unwrap();
2614 assert!(
2615 result.contains("TOOL_MODE"),
2616 "With tool_choice=auto, tools should be included. Got: {}",
2617 result
2618 );
2619 }
2620
2621 #[test]
2622 fn test_no_exclude_tools_keeps_when_tool_choice_none() {
2623 let formatter = tool_aware_formatter(false);
2624 let request = request_with_tool_choice("none");
2625 let result = formatter.render(&request).unwrap();
2626 assert!(
2627 result.contains("TOOL_MODE"),
2628 "With exclude_tools=false and tool_choice=none, tools should NOT be stripped. Got: {}",
2629 result
2630 );
2631 }
2632
2633 #[test]
2634 fn test_inject_reasoning_content_segments_with_tool_calls() {
2635 let mut messages = serde_json::json!([
2637 {
2638 "role": "user",
2639 "content": "What is sqrt(144) and sqrt(256)?"
2640 },
2641 {
2642 "role": "assistant",
2643 "content": "Let me calculate those.",
2644 "reasoning_content": ["I need to compute sqrt(144)", "Now sqrt(256)", ""],
2645 "tool_calls": [
2646 {
2647 "id": "call_0",
2648 "type": "function",
2649 "function": {
2650 "name": "calculator",
2651 "arguments": "{\"expr\": \"sqrt(144)\"}"
2652 }
2653 },
2654 {
2655 "id": "call_1",
2656 "type": "function",
2657 "function": {
2658 "name": "calculator",
2659 "arguments": "{\"expr\": \"sqrt(256)\"}"
2660 }
2661 }
2662 ]
2663 }
2664 ]);
2665
2666 inject_reasoning_content_into_messages(&mut messages);
2667
2668 let assistant = &messages[1];
2669
2670 assert!(
2672 assistant.get("reasoning_content").is_none(),
2673 "reasoning_content should be removed after injection"
2674 );
2675
2676 let content = assistant["content"].as_str().unwrap();
2678 assert!(
2679 content.starts_with("<think>I need to compute sqrt(144)</think>"),
2680 "content should start with first reasoning segment, got: {}",
2681 content
2682 );
2683 assert!(
2684 content.contains("<think>Now sqrt(256)</think>"),
2685 "content should contain second reasoning segment"
2686 );
2687 assert!(
2689 !content.contains("<think></think>"),
2690 "empty segments should be skipped"
2691 );
2692 assert!(
2694 content.ends_with("Let me calculate those."),
2695 "original content should be at the end, got: {}",
2696 content
2697 );
2698
2699 assert!(assistant.get("tool_calls").is_some());
2701 assert_eq!(assistant["tool_calls"].as_array().unwrap().len(), 2);
2702 }
2703
2704 #[test]
2705 fn test_gemma4_template_renders_reasoning_content_segments_around_tool_calls() {
2706 let formatter = make_gemma4_tool_formatter_for_tests();
2707 assert!(
2708 formatter.tool_use_template_handles_reasoning,
2709 "Gemma4 template adaptation should make reasoning_content native"
2710 );
2711
2712 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2713 "model": "gemma4-test",
2714 "messages": [
2715 {"role": "user", "content": "inspect two things"},
2716 {
2717 "role": "assistant",
2718 "content": null,
2719 "reasoning_content": [
2720 "Think before the first call.",
2721 "Think before the second call.",
2722 "Think after both calls."
2723 ],
2724 "tool_calls": [
2725 {
2726 "id": "call_0",
2727 "type": "function",
2728 "function": {
2729 "name": "first_tool",
2730 "arguments": "{\"path\":\".\"}"
2731 }
2732 },
2733 {
2734 "id": "call_1",
2735 "type": "function",
2736 "function": {
2737 "name": "second_tool",
2738 "arguments": "{\"path\":\"/tmp\"}"
2739 }
2740 }
2741 ]
2742 }
2743 ]
2744 }))
2745 .unwrap();
2746
2747 let rendered = formatter.render(&request).unwrap();
2748
2749 let expected = concat!(
2750 "<|channel>thought\nThink before the first call.\n<channel|>",
2751 "<|tool_call>call:first_tool{path:.}<tool_call|>",
2752 "<|channel>thought\nThink before the second call.\n<channel|>",
2753 "<|tool_call>call:second_tool{path:/tmp}<tool_call|>",
2754 "<|channel>thought\nThink after both calls.\n<channel|>"
2755 );
2756 assert!(
2757 rendered.contains(expected),
2758 "Gemma4 reasoning segments should stay adjacent to their tool calls, got: {rendered}"
2759 );
2760 assert!(!rendered.contains("<think>"));
2761 assert!(!rendered.contains("reasoning_content"));
2762 }
2763
2764 #[test]
2765 fn test_gemma4_template_renders_reasoning_content_without_tool_calls() {
2766 let formatter = make_gemma4_tool_formatter_for_tests();
2767 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2768 "model": "gemma4-test",
2769 "messages": [
2770 {"role": "user", "content": "answer directly"},
2771 {
2772 "role": "assistant",
2773 "content": "Direct answer.",
2774 "reasoning_content": "Private thought."
2775 }
2776 ]
2777 }))
2778 .unwrap();
2779
2780 let rendered = formatter.render(&request).unwrap();
2781
2782 assert!(
2783 rendered.contains("<|channel>thought\nPrivate thought.\n<channel|>Direct answer."),
2784 "Gemma4 reasoning_content should render in the thought channel, got: {rendered}"
2785 );
2786 assert!(!rendered.contains("<think>"));
2787 assert!(!rendered.contains("reasoning_content"));
2788 }
2789
2790 #[test]
2796 fn test_reasoning_flag_is_per_template_not_global() {
2797 const PLAIN_DEFAULT: &str = "{{ bos_token }}{%- for message in messages -%}\
2800 {{ message['role'] }}: {{ message['content'] }}\n{%- endfor -%}";
2801
2802 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2803 "chat_template": [
2804 {"default": PLAIN_DEFAULT},
2805 {"tool_use": gemma4_tool_template_for_tests()},
2806 ]
2807 }))
2808 .unwrap();
2809 let formatter =
2810 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2811
2812 assert!(
2816 formatter.tool_use_template_handles_reasoning,
2817 "adapted gemma4 tool_use template should handle reasoning natively"
2818 );
2819 assert!(
2820 !formatter.default_template_handles_reasoning,
2821 "plain default template does not reference reasoning_content"
2822 );
2823
2824 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2827 "model": "gemma4-test",
2828 "messages": [
2829 {"role": "user", "content": "answer directly"},
2830 {
2831 "role": "assistant",
2832 "content": "Direct answer.",
2833 "reasoning_content": "Private thought."
2834 }
2835 ]
2836 }))
2837 .unwrap();
2838
2839 let rendered = formatter.render(&request).unwrap();
2840 assert!(
2841 rendered.contains("<think>Private thought.</think>Direct answer."),
2842 "reasoning must be injected on the no-tool default path, got: {rendered}"
2843 );
2844 }
2845
2846 #[test]
2847 fn test_inject_reasoning_content_text_variant() {
2848 let mut messages = serde_json::json!([
2849 {
2850 "role": "assistant",
2851 "content": "The answer is 42.",
2852 "reasoning_content": "Let me think about this carefully."
2853 }
2854 ]);
2855
2856 inject_reasoning_content_into_messages(&mut messages);
2857
2858 let assistant = &messages[0];
2859 assert!(assistant.get("reasoning_content").is_none());
2860 let content = assistant["content"].as_str().unwrap();
2861 assert_eq!(
2862 content,
2863 "<think>Let me think about this carefully.</think>The answer is 42."
2864 );
2865 }
2866
2867 #[test]
2868 fn test_inject_reasoning_content_null_content() {
2869 let mut messages = serde_json::json!([
2871 {
2872 "role": "assistant",
2873 "content": null,
2874 "reasoning_content": "Thinking...",
2875 "tool_calls": [{"id": "call_0", "type": "function", "function": {"name": "f", "arguments": "{}"}}]
2876 }
2877 ]);
2878
2879 inject_reasoning_content_into_messages(&mut messages);
2880
2881 let content = messages[0]["content"].as_str().unwrap();
2882 assert_eq!(content, "<think>Thinking...</think>");
2883 assert!(messages[0].get("reasoning_content").is_none());
2884 }
2885
2886 #[test]
2887 fn test_inject_reasoning_content_skips_non_assistant() {
2888 let mut messages = serde_json::json!([
2889 {
2890 "role": "user",
2891 "content": "hello",
2892 "reasoning_content": "should not be touched"
2893 }
2894 ]);
2895
2896 inject_reasoning_content_into_messages(&mut messages);
2897
2898 assert!(messages[0].get("reasoning_content").is_some());
2900 }
2901
2902 fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
2904 use super::tokcfg::ChatTemplate;
2905 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2906
2907 let template = r#"{%- for message in messages %}{{ message.role }}: {{ message.content }}
2910{%- endfor %}
2911{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2912
2913 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2914 "chat_template": template
2915 }))
2916 .unwrap();
2917
2918 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2919 }
2920
2921 #[test]
2924 fn test_reasoning_content_text_roundtrip_render() {
2925 use super::OAIPromptFormatter;
2926 let formatter = make_test_formatter();
2927
2928 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2929 "model": "test-model",
2930 "messages": [
2931 {"role": "user", "content": "What is sqrt(144)?"},
2932 {
2933 "role": "assistant",
2934 "content": "The answer is 12.",
2935 "reasoning_content": "I need to compute the square root of 144."
2936 },
2937 {"role": "user", "content": "Are you sure?"}
2938 ]
2939 }))
2940 .unwrap();
2941
2942 let rendered = formatter.render(&request).unwrap();
2943
2944 assert!(
2945 rendered.contains("<think>I need to compute the square root of 144.</think>"),
2946 "reasoning_content must appear as <think> block, got: {}",
2947 rendered
2948 );
2949 assert!(
2950 rendered.contains("The answer is 12."),
2951 "original content must be preserved"
2952 );
2953 assert!(
2954 !rendered.contains("reasoning_content"),
2955 "raw reasoning_content field should not leak into prompt"
2956 );
2957 }
2958
2959 #[test]
2963 fn test_reasoning_content_agentic_tool_call_roundtrip_render() {
2964 use super::OAIPromptFormatter;
2965 let formatter = make_test_formatter();
2966
2967 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2968 "model": "test-model",
2969 "messages": [
2970 {"role": "user", "content": "What is sqrt(144) + sqrt(256)?"},
2971 {
2972 "role": "assistant",
2973 "content": null,
2974 "reasoning_content": "I need to compute both square roots. Let me start with sqrt(144).",
2975 "tool_calls": [{
2976 "id": "call_0",
2977 "type": "function",
2978 "function": {
2979 "name": "calculator",
2980 "arguments": "{\"expr\": \"sqrt(144)\"}"
2981 }
2982 }]
2983 },
2984 {
2985 "role": "tool",
2986 "tool_call_id": "call_0",
2987 "content": "12"
2988 },
2989 {
2990 "role": "assistant",
2991 "content": "sqrt(144) = 12 and sqrt(256) = 16, so the answer is 28.",
2992 "reasoning_content": "Got 12 for sqrt(144). Now sqrt(256) = 16. Sum is 28."
2993 },
2994 {"role": "user", "content": "Thanks!"}
2995 ]
2996 }))
2997 .unwrap();
2998
2999 let rendered = formatter.render(&request).unwrap();
3000
3001 assert!(
3003 rendered.contains("<think>I need to compute both square roots"),
3004 "first turn reasoning must be in prompt, got: {}",
3005 rendered
3006 );
3007 assert!(
3009 rendered.contains("<think>Got 12 for sqrt(144)"),
3010 "second turn reasoning must be in prompt"
3011 );
3012 assert!(
3013 rendered.contains("the answer is 28"),
3014 "final answer content must be preserved"
3015 );
3016 assert!(
3018 !rendered.contains("reasoning_content"),
3019 "raw reasoning_content field should not leak into prompt"
3020 );
3021 }
3022
3023 #[test]
3025 fn test_reasoning_injected_when_template_ignores_it() {
3026 use super::OAIPromptFormatter;
3027 let formatter = make_test_formatter();
3028
3029 assert!(!formatter.default_template_handles_reasoning);
3031 assert!(!formatter.tool_use_template_handles_reasoning);
3032
3033 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
3034 "model": "test-model",
3035 "messages": [
3036 {"role": "user", "content": "Hello"},
3037 {
3038 "role": "assistant",
3039 "content": "Hi.",
3040 "reasoning_content": "The user said hello."
3041 },
3042 {"role": "user", "content": "Bye"}
3043 ]
3044 }))
3045 .unwrap();
3046
3047 let rendered = formatter.render(&request).unwrap();
3048 assert!(
3049 rendered.contains("<think>The user said hello.</think>"),
3050 "injection must happen when template ignores reasoning_content, got: {}",
3051 rendered
3052 );
3053 }
3054
3055 #[test]
3057 fn test_reasoning_not_injected_when_template_handles_it() {
3058 use super::tokcfg::ChatTemplate;
3059 use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
3060
3061 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>
3063{%- endif %}{{ message.role }}: {{ message.content }}
3064{%- endfor %}
3065{%- if add_generation_prompt %}assistant:{%- endif %}"#;
3066
3067 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
3068 "chat_template": template
3069 }))
3070 .unwrap();
3071
3072 let formatter =
3073 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
3074
3075 assert!(formatter.default_template_handles_reasoning);
3077 assert!(formatter.tool_use_template_handles_reasoning);
3078
3079 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
3080 "model": "test-model",
3081 "messages": [
3082 {"role": "user", "content": "Hello"},
3083 {
3084 "role": "assistant",
3085 "content": "Hi.",
3086 "reasoning_content": "The user said hello."
3087 },
3088 {"role": "user", "content": "Bye"}
3089 ]
3090 }))
3091 .unwrap();
3092
3093 let rendered = formatter.render(&request).unwrap();
3094
3095 assert!(
3097 rendered.contains("<think>The user said hello.</think>"),
3098 "template must render reasoning_content natively, got: {}",
3099 rendered
3100 );
3101 let think_count = rendered.matches("<think>").count();
3103 assert_eq!(
3104 think_count, 1,
3105 "must have exactly one <think> block (from template), got {} in: {}",
3106 think_count, rendered
3107 );
3108 }
3109
3110 const QWEN3_THINKING_TEMPLATE: &str = r##"{%- if tools %}
3114 {{- '<|im_start|>system\n' }}
3115 {%- if messages[0].role == 'system' %}
3116 {{- messages[0].content + '\n\n' }}
3117 {%- endif %}
3118 {{- "# 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>" }}
3119 {%- for tool in tools %}
3120 {{- "\n" }}
3121 {{- tool | tojson }}
3122 {%- endfor %}
3123 {{- "\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" }}
3124{%- else %}
3125 {%- if messages[0].role == 'system' %}
3126 {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
3127 {%- endif %}
3128{%- endif %}
3129{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
3130{%- for message in messages[::-1] %}
3131 {%- set index = (messages|length - 1) - loop.index0 %}
3132 {%- 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>')) %}
3133 {%- set ns.multi_step_tool = false %}
3134 {%- set ns.last_query_index = index %}
3135 {%- endif %}
3136{%- endfor %}
3137{%- for message in messages %}
3138 {%- if message.content is string %}
3139 {%- set content = message.content %}
3140 {%- else %}
3141 {%- set content = '' %}
3142 {%- endif %}
3143 {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
3144 {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
3145 {%- elif message.role == "assistant" %}
3146 {%- set reasoning_content = '' %}
3147 {%- if message.reasoning_content is string %}
3148 {%- set reasoning_content = message.reasoning_content %}
3149 {%- else %}
3150 {%- if '</think>' in content %}
3151 {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
3152 {%- set content = content.split('</think>')[-1].lstrip('\n') %}
3153 {%- endif %}
3154 {%- endif %}
3155 {%- if loop.index0 > ns.last_query_index %}
3156 {%- if loop.last or (not loop.last and reasoning_content) %}
3157 {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
3158 {%- else %}
3159 {{- '<|im_start|>' + message.role + '\n' + content }}
3160 {%- endif %}
3161 {%- else %}
3162 {{- '<|im_start|>' + message.role + '\n' + content }}
3163 {%- endif %}
3164 {%- if message.tool_calls %}
3165 {%- for tool_call in message.tool_calls %}
3166 {%- if (loop.first and content) or (not loop.first) %}
3167 {{- '\n' }}
3168 {%- endif %}
3169 {%- if tool_call.function %}
3170 {%- set tool_call = tool_call.function %}
3171 {%- endif %}
3172 {{- '<tool_call>\n{"name": "' }}
3173 {{- tool_call.name }}
3174 {{- '", "arguments": ' }}
3175 {%- if tool_call.arguments is string %}
3176 {{- tool_call.arguments }}
3177 {%- else %}
3178 {{- tool_call.arguments | tojson }}
3179 {%- endif %}
3180 {{- '}\n</tool_call>' }}
3181 {%- endfor %}
3182 {%- endif %}
3183 {{- '<|im_end|>\n' }}
3184 {%- elif message.role == "tool" %}
3185 {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
3186 {{- '<|im_start|>user' }}
3187 {%- endif %}
3188 {{- '\n<tool_response>\n' }}
3189 {{- content }}
3190 {{- '\n</tool_response>' }}
3191 {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
3192 {{- '<|im_end|>\n' }}
3193 {%- endif %}
3194 {%- endif %}
3195{%- endfor %}
3196{%- if add_generation_prompt %}
3197 {{- '<|im_start|>assistant\n<think>\n' }}
3198{%- endif %}"##;
3199
3200 fn qwen3_thinking_formatter() -> HfTokenizerConfigJsonFormatter {
3201 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
3202 "chat_template": QWEN3_THINKING_TEMPLATE,
3203 }))
3204 .unwrap();
3205 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
3206 }
3207
3208 #[test]
3209 fn test_qwen3_thinking_template_flags_detected() {
3210 let formatter = qwen3_thinking_formatter();
3211 assert!(
3212 formatter.tool_use_template_handles_reasoning,
3213 "template references reasoning_content directly"
3214 );
3215 assert!(
3218 formatter.default_template_handles_tool_calls_arguments_string,
3219 "default template branches on `arguments is string`"
3220 );
3221 assert!(
3222 formatter.tool_use_template_handles_tool_calls_arguments_string,
3223 "tool_use template branches on `arguments is string`"
3224 );
3225 }
3226
3227 #[test]
3237 fn test_qwen3_thinking_append_only_across_tool_use_turn() {
3238 let formatter = qwen3_thinking_formatter();
3239
3240 let tools = serde_json::json!([{
3241 "type": "function",
3242 "function": {
3243 "name": "get_weather",
3244 "description": "Get the current weather for a location",
3245 "parameters": {
3246 "type": "object",
3247 "properties": {
3248 "location": {"type": "string"},
3249 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
3250 },
3251 "required": ["location"]
3252 }
3253 }
3254 }]);
3255
3256 let turn1_request: NvCreateChatCompletionRequest =
3258 serde_json::from_value(serde_json::json!({
3259 "model": "qwen3-thinking",
3260 "messages": [
3261 {"role": "system", "content": "You are a helpful assistant."},
3262 {"role": "user", "content": "What's the weather in San Francisco?"},
3263 ],
3264 "tools": tools,
3265 }))
3266 .unwrap();
3267 let p1 = formatter.render(&turn1_request).unwrap();
3268
3269 let model_emitted = "I'll call get_weather for SF.\n\
3273 </think>\n\n\
3274 <tool_call>\n\
3275 {\"name\": \"get_weather\", \"arguments\": {\"location\": \"San Francisco\", \"unit\": \"celsius\"}}\n\
3276 </tool_call><|im_end|>\n";
3277 let wire_after_t1 = format!("{p1}{model_emitted}");
3278
3279 let turn2_request: NvCreateChatCompletionRequest =
3282 serde_json::from_value(serde_json::json!({
3283 "model": "qwen3-thinking",
3284 "messages": [
3285 {"role": "system", "content": "You are a helpful assistant."},
3286 {"role": "user", "content": "What's the weather in San Francisco?"},
3287 {
3288 "role": "assistant",
3289 "content": "",
3290 "reasoning_content": "I'll call get_weather for SF.",
3291 "tool_calls": [{
3292 "id": "call_sf",
3293 "type": "function",
3294 "function": {
3295 "name": "get_weather",
3296 "arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
3297 }
3298 }]
3299 },
3300 {
3301 "role": "tool",
3302 "tool_call_id": "call_sf",
3303 "content": "{\"temp\": 18, \"conditions\": \"Foggy\"}"
3304 }
3305 ],
3306 "tools": tools,
3307 }))
3308 .unwrap();
3309 let p2 = formatter.render(&turn2_request).unwrap();
3310
3311 if !p2.starts_with(&wire_after_t1) {
3312 let div = wire_after_t1
3314 .as_bytes()
3315 .iter()
3316 .zip(p2.as_bytes())
3317 .position(|(a, b)| a != b)
3318 .unwrap_or_else(|| wire_after_t1.len().min(p2.len()));
3319 let lo = div.saturating_sub(40);
3320 panic!(
3321 "turn-2 prompt is NOT a prefix-extension of [turn-1 + model bytes]\n \
3322 diverges at byte {div}\n \
3323 wire ends: ...{}|{}\n \
3324 t2 has: ...{}|{}",
3325 String::from_utf8_lossy(&wire_after_t1.as_bytes()[lo..div]),
3326 String::from_utf8_lossy(
3327 &wire_after_t1.as_bytes()[div..(div + 60).min(wire_after_t1.len())]
3328 ),
3329 String::from_utf8_lossy(&p2.as_bytes()[lo..div]),
3330 String::from_utf8_lossy(&p2.as_bytes()[div..(div + 60).min(p2.len())]),
3331 );
3332 }
3333
3334 let suffix = &p2[wire_after_t1.len()..];
3337 assert!(
3338 suffix.contains("<tool_response>"),
3339 "appended bytes must include the tool response, got: {suffix}"
3340 );
3341 assert!(
3342 suffix.ends_with("<|im_start|>assistant\n<think>\n"),
3343 "appended bytes must end with the next generation prompt, got: {suffix}"
3344 );
3345 }
3346}