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 if system_normalization.is_required() {
600 normalize_system_messages(&mut messages_for_template, system_normalization);
601 }
602
603 messages_for_template = serde_json::to_value(may_be_fix_msg_content(
604 messages_for_template,
605 self.requires_content_arrays,
606 self.image_placeholder_template,
607 ))
608 .unwrap();
609
610 if !template_handles_tool_calls_args_string {
617 normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
618 }
619 normalize_function_call_arguments_in_messages(&mut messages_for_template);
623
624 if !template_handles_reasoning {
629 inject_reasoning_content_into_messages(&mut messages_for_template);
630 }
631
632 let ctx = context! {
633 messages => messages_for_template,
634 tools => tools,
635 bos_token => self.config.bos_tok(),
636 eos_token => self.config.eos_tok(),
637 unk_token => self.config.unk_tok(),
638 add_generation_prompt => add_generation_prompt,
639 ..mixins
640 };
641
642 let ctx = if let Some(args) = req.chat_template_args() {
644 let extra = Value::from_serialize(args);
645 context! { ..ctx, ..extra }
646 } else {
647 ctx
648 };
649
650 let tmpl: minijinja::Template<'_, '_> = self.env.get_template(template_name)?;
651 Ok(tmpl.render(&ctx)?)
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use dynamo_protocols::types::ChatCompletionRequestMessage as Msg;
659 use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
662 use minijinja::{Environment, context};
663
664 use super::super::tokcfg::ChatTemplate as SysChatTemplate;
667 use super::super::{
668 ContextMixins as SysMixins, HfTokenizerConfigJsonFormatter as SysFormatter,
669 };
670
671 fn formatter_for(template: &str) -> SysFormatter {
672 let ct: SysChatTemplate = serde_json::from_value(json!({
674 "chat_template": template,
675 "bos_token": "<s>",
676 "eos_token": "</s>",
677 "unk_token": "<unk>",
678 }))
679 .unwrap();
680 SysFormatter::new(ct, SysMixins::new(&[])).unwrap()
681 }
682
683 fn formatter_for_templates(default: &str, tool_use: &str) -> SysFormatter {
684 let ct: SysChatTemplate = serde_json::from_value(json!({
685 "chat_template": [
686 {"default": default},
687 {"tool_use": tool_use},
688 ],
689 "bos_token": "<s>",
690 "eos_token": "</s>",
691 "unk_token": "<unk>",
692 }))
693 .unwrap();
694 SysFormatter::new(ct, SysMixins::new(&[])).unwrap()
695 }
696
697 fn try_formatter_for(template: &str) -> Option<SysFormatter> {
698 let ct: SysChatTemplate = serde_json::from_value(json!({
699 "chat_template": template,
700 "bos_token": "<s>",
701 "eos_token": "</s>",
702 "unk_token": "<unk>",
703 }))
704 .ok()?;
705 SysFormatter::new(ct, SysMixins::new(&[])).ok()
706 }
707
708 fn render_shape(f: &SysFormatter, messages: serde_json::Value) -> Result<String> {
709 let req: NvCreateChatCompletionRequest =
710 serde_json::from_value(json!({ "model": "test", "messages": messages })).unwrap();
711 f.render(&req)
712 }
713
714 fn render_shape_with_tools(f: &SysFormatter, messages: serde_json::Value) -> Result<String> {
715 let req: NvCreateChatCompletionRequest = serde_json::from_value(json!({
716 "model": "test",
717 "messages": messages,
718 "tools": [{
719 "type": "function",
720 "function": {"name": "noop", "parameters": {}}
721 }]
722 }))
723 .unwrap();
724 f.render(&req)
725 }
726
727 const PERMISSIVE_TMPL: &str = concat!(
728 "{%- for m in messages -%}",
729 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
730 "{%- endfor -%}"
731 );
732 const STRICT_LEADING_TMPL: &str = concat!(
734 "{%- for m in messages -%}",
735 "{%- if m.role == 'system' and not loop.first -%}",
736 "{{ raise_exception('System message must be at the beginning.') }}",
737 "{%- endif -%}",
738 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
739 "{%- endfor -%}"
740 );
741 const ALTERNATION_TMPL: &str = concat!(
743 "{%- set ns = namespace(prev='') -%}",
744 "{%- for m in messages -%}",
745 "{%- if m.role == 'user' and ns.prev == 'user' -%}",
746 "{{ raise_exception('Conversation roles must alternate.') }}",
747 "{%- endif -%}",
748 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
749 "{%- set ns.prev = m.role -%}",
750 "{%- endfor -%}"
751 );
752 const STRICT_BOTH_TMPL: &str = concat!(
754 "{%- set ns = namespace(prev='') -%}",
755 "{%- for m in messages -%}",
756 "{%- if m.role == 'system' and not loop.first -%}",
757 "{{ raise_exception('System message must be at the beginning.') }}",
758 "{%- endif -%}",
759 "{%- if m.role == 'user' and ns.prev == 'user' -%}",
760 "{{ raise_exception('Conversation roles must alternate.') }}",
761 "{%- endif -%}",
762 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
763 "{%- set ns.prev = m.role -%}",
764 "{%- endfor -%}"
765 );
766 const DEFAULT_NONE_GATED_TMPL: &str = concat!(
769 "{%- set strict = tools is not none -%}",
770 "{%- for m in messages -%}",
771 "{%- if strict and m.role == 'system' and not loop.first -%}",
772 "{{ raise_exception('System message must be at the beginning.') }}",
773 "{%- endif -%}",
774 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
775 "{%- endfor -%}"
776 );
777 const TOOL_NONEMPTY_GATED_TMPL: &str = concat!(
778 "{%- set strict = tools|length > 0 -%}",
779 "{%- for m in messages -%}",
780 "{%- if strict and m.role == 'system' and not loop.first -%}",
781 "{{ raise_exception('System message must be at the beginning.') }}",
782 "{%- endif -%}",
783 "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
784 "{%- endfor -%}"
785 );
786 const STRICT_ARRAY_TMPL: &str = concat!(
788 "{%- for m in messages -%}",
789 "{%- if m.role == 'system' and not loop.first -%}",
790 "{{ raise_exception('System message must be at the beginning.') }}",
791 "{%- endif -%}",
792 "<|im_start|>{{ m.role }}\n",
793 "{%- if m.content is not string -%}",
794 "{%- for part in m.content -%}{{ part.text }}{%- endfor -%}",
795 "{%- endif -%}",
796 "<|im_end|>\n",
797 "{%- endfor -%}"
798 );
799
800 fn claude_shape() -> serde_json::Value {
802 json!([
803 {"role": "system", "content": "You are Claude Code."},
804 {"role": "user", "content": "hello"},
805 {"role": "system", "content": "mid-conversation reminder"},
806 ])
807 }
808
809 fn all_restrictions() -> SystemNormalization {
810 SystemNormalization {
811 demote_nonleading_system: true,
812 coalesce_consecutive_users: true,
813 }
814 }
815
816 #[test]
817 fn permissive_template_is_not_flagged_and_renders_untouched() {
818 let f = formatter_for(PERMISSIVE_TMPL);
819 assert!(!f.default_system_normalization.is_required());
820 assert!(!f.tool_use_system_normalization.is_required());
821 let out = render_shape(&f, claude_shape()).unwrap();
822 assert!(out.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
823 }
824
825 #[test]
826 fn strict_leading_template_demotes_mid_system_but_keeps_user_turns_apart() {
827 let f = formatter_for(STRICT_LEADING_TMPL);
828 assert!(f.default_system_normalization.demote_nonleading_system);
829 assert!(!f.default_system_normalization.coalesce_consecutive_users);
831
832 let out = render_shape(&f, claude_shape()).unwrap();
834 assert_eq!(out.matches("<|im_start|>system").count(), 1);
835 assert!(out.contains("<|im_start|>user\nhello<|im_end|>"));
836 assert!(out.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
837 }
838
839 #[test]
840 fn alternation_template_coalesces_users_but_keeps_mid_system() {
841 let f = formatter_for(ALTERNATION_TMPL);
842 assert!(f.default_system_normalization.coalesce_consecutive_users);
843 assert!(!f.default_system_normalization.demote_nonleading_system);
845
846 let out = render_shape(&f, claude_shape()).unwrap();
847 assert!(out.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
848
849 let out = render_shape(
850 &f,
851 json!([
852 {"role": "system", "content": "s"},
853 {"role": "user", "content": "hello"},
854 {"role": "user", "content": "again"},
855 ]),
856 )
857 .unwrap();
858 assert_eq!(out.matches("<|im_start|>user").count(), 1);
859 assert!(out.contains("<|im_start|>user\nhello\n\nagain<|im_end|>"));
860 }
861
862 #[test]
863 fn strict_both_template_demotes_then_coalesces() {
864 let f = formatter_for(STRICT_BOTH_TMPL);
865 assert!(f.default_system_normalization.demote_nonleading_system);
866 assert!(f.default_system_normalization.coalesce_consecutive_users);
867
868 let out = render_shape(&f, claude_shape()).unwrap();
869 assert_eq!(out.matches("<|im_start|>system").count(), 1);
870 assert!(out.contains("<|im_start|>user\nhello\n\nmid-conversation reminder<|im_end|>"));
871 }
872
873 #[test]
874 fn system_normalization_flag_is_selected_per_template() {
875 let f = formatter_for_templates(PERMISSIVE_TMPL, STRICT_LEADING_TMPL);
876 assert!(!f.default_system_normalization.is_required());
877 assert!(f.tool_use_system_normalization.is_required());
878
879 let no_tools = render_shape(&f, claude_shape()).unwrap();
880 assert!(no_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
881 let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
882 assert_eq!(with_tools.matches("<|im_start|>system").count(), 1);
883 assert!(with_tools.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
884
885 let f = formatter_for_templates(STRICT_LEADING_TMPL, PERMISSIVE_TMPL);
886 assert!(f.default_system_normalization.is_required());
887 assert!(!f.tool_use_system_normalization.is_required());
888 let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
889 assert!(with_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
890 }
891
892 #[test]
893 fn system_normalization_probe_uses_runtime_tools_shape() {
894 let f = formatter_for_templates(DEFAULT_NONE_GATED_TMPL, TOOL_NONEMPTY_GATED_TMPL);
895 assert!(!f.default_system_normalization.is_required());
896 assert!(f.tool_use_system_normalization.is_required());
897
898 let no_tools = render_shape(&f, claude_shape()).unwrap();
899 assert!(no_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
900
901 let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
902 assert_eq!(with_tools.matches("<|im_start|>system").count(), 1);
903 assert!(with_tools.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
904 }
905
906 #[test]
907 fn system_normalization_precedes_required_content_array_conversion() {
908 let f = formatter_for(STRICT_ARRAY_TMPL);
909 assert!(f.requires_content_arrays);
910 assert!(f.default_system_normalization.demote_nonleading_system);
911
912 let out = render_shape(
913 &f,
914 json!([
915 {"role": "system", "content": "A"},
916 {"role": "system", "content": "B"},
917 {"role": "user", "content": "hello"},
918 ]),
919 )
920 .unwrap();
921 assert!(out.contains("A\n\nB"));
922 }
923
924 #[test]
925 fn normalize_preserves_multimodal_user_content_and_fields() {
926 let mut m = json!([
927 {
928 "role": "user",
929 "name": "kept",
930 "content": [
931 {"type": "text", "text": "look"},
932 {"type": "image"},
933 ],
934 },
935 {"role": "system", "content": "remember"},
936 ]);
937 normalize_system_messages(&mut m, all_restrictions());
938 assert_eq!(
939 m,
940 json!([{
941 "role": "user",
942 "name": "kept",
943 "content": [
944 {"type": "text", "text": "look"},
945 {"type": "image"},
946 {"type": "text", "text": "remember"},
947 ],
948 }])
949 );
950 }
951
952 #[test]
955 fn coalesce_preserves_multimodal_content_of_the_merged_turn() {
956 let mut m = json!([
957 {"role": "user", "content": "look"},
958 {"role": "user", "content": [
959 {"type": "text", "text": "at this"},
960 {"type": "image_url", "image_url": {"url": "http://img"}},
961 ]},
962 ]);
963 normalize_system_messages(&mut m, all_restrictions());
964 assert_eq!(
965 m,
966 json!([{
967 "role": "user",
968 "content": [
969 {"type": "text", "text": "look"},
970 {"type": "text", "text": "at this"},
971 {"type": "image_url", "image_url": {"url": "http://img"}},
972 ],
973 }])
974 );
975 }
976
977 #[test]
978 fn normalize_merges_leading_run_and_coalesces() {
979 let mut m = json!([
980 {"role": "system", "content": "A"},
981 {"role": "system", "content": "B"},
982 {"role": "user", "content": "hi"},
983 {"role": "system", "content": "reminder"},
984 ]);
985 normalize_system_messages(&mut m, all_restrictions());
986 assert_eq!(
987 m,
988 json!([
989 {"role": "system", "content": "A\n\nB"},
990 {"role": "user", "content": "hi\n\nreminder"},
991 ])
992 );
993 }
994
995 #[test]
998 fn each_restriction_applies_only_its_own_rewrite() {
999 let shape = json!([
1000 {"role": "system", "content": "A"},
1001 {"role": "system", "content": "B"},
1002 {"role": "user", "content": "hi"},
1003 {"role": "system", "content": "reminder"},
1004 ]);
1005
1006 let mut demote_only = shape.clone();
1007 normalize_system_messages(
1008 &mut demote_only,
1009 SystemNormalization {
1010 demote_nonleading_system: true,
1011 coalesce_consecutive_users: false,
1012 },
1013 );
1014 assert_eq!(
1015 demote_only,
1016 json!([
1017 {"role": "system", "content": "A\n\nB"},
1018 {"role": "user", "content": "hi"},
1019 {"role": "user", "content": "reminder"},
1020 ])
1021 );
1022
1023 let mut coalesce_only = shape.clone();
1024 normalize_system_messages(
1025 &mut coalesce_only,
1026 SystemNormalization {
1027 demote_nonleading_system: false,
1028 coalesce_consecutive_users: true,
1029 },
1030 );
1031 assert_eq!(coalesce_only, shape);
1032 }
1033
1034 #[test]
1035 fn normalize_preserves_array_system_content() {
1036 let mut m = json!([
1037 {"role": "user", "content": "hi"},
1038 {"role": "system", "content": [{"type": "text", "text": "one"},
1039 {"type": "text", "text": "two"}]},
1040 ]);
1041 normalize_system_messages(&mut m, all_restrictions());
1042 assert_eq!(
1043 m,
1044 json!([{"role": "user", "content": [
1045 {"type": "text", "text": "hi"},
1046 {"type": "text", "text": "one"},
1047 {"type": "text", "text": "two"},
1048 ]}])
1049 );
1050 }
1051
1052 #[test]
1061 #[ignore]
1062 fn adaptive_system_corpus_audit() {
1063 let dir =
1064 std::env::var("TEMPLATE_CORPUS").expect("set TEMPLATE_CORPUS to the templates dir");
1065 let manifest: serde_json::Value =
1066 serde_json::from_str(&std::fs::read_to_string(format!("{dir}/manifest.json")).unwrap())
1067 .unwrap();
1068
1069 let sys = |c: &str| json!({"role": "system", "content": c});
1072 let usr = |c: &str| json!({"role": "user", "content": c});
1073 let asst = |c: &str| json!({"role": "assistant", "content": c});
1074 let shapes: Vec<(&str, serde_json::Value)> = vec![
1075 ("turn1", json!([sys("s"), usr("u"), sys("mid")])),
1076 (
1077 "multiturn",
1078 json!([sys("s"), usr("u"), sys("mid"), asst("a"), usr("u2")]),
1079 ),
1080 (
1081 "mid_after_asst",
1082 json!([sys("s"), usr("u"), asst("a"), sys("mid"), usr("u2")]),
1083 ),
1084 ("double_leading", json!([sys("s0"), sys("s1"), usr("u")])),
1085 ("consec_user", json!([sys("s"), usr("u0"), usr("u1")])),
1086 (
1087 "tail_reminder",
1088 json!([
1089 sys("s"),
1090 usr("u"),
1091 asst("a"),
1092 usr("u2"),
1093 sys("mid"),
1094 usr("u3")
1095 ]),
1096 ),
1097 ("leading_only_baseline", json!([sys("s"), usr("u")])),
1098 ];
1099
1100 let mut total = 0usize;
1101 let mut flagged = 0usize;
1102 let mut demote_only = 0usize;
1103 let mut coalesce = 0usize;
1104 let mut failures: Vec<String> = Vec::new();
1105 for (file, meta) in manifest.as_object().unwrap() {
1106 let tmpl = std::fs::read_to_string(format!("{dir}/{file}.jinja")).unwrap();
1107 let model = meta["model"].as_str().unwrap_or(file);
1108 let f = match try_formatter_for(&tmpl) {
1111 Some(f) => f,
1112 None => {
1113 eprintln!("[skip-compile] {model}");
1114 continue;
1115 }
1116 };
1117 if render_shape(&f, json!([sys("s"), usr("u")])).is_err() {
1120 eprintln!("[skip-baseline] {model}");
1121 continue;
1122 }
1123 total += 1;
1124 let rules = f.default_system_normalization;
1125 let flag = rules.is_required();
1126 if flag {
1127 flagged += 1;
1128 }
1129 if rules.demote_nonleading_system {
1130 demote_only += usize::from(!rules.coalesce_consecutive_users);
1131 }
1132 if rules.coalesce_consecutive_users {
1133 coalesce += 1;
1134 }
1135 for (name, shape) in &shapes {
1136 if render_shape(&f, shape.clone()).is_err() {
1137 failures.push(format!("{model} | shape={name} | flag={flag}"));
1138 }
1139 }
1140 if flag {
1141 eprintln!(
1142 "[ok] demote={} coalesce={} {model}",
1143 rules.demote_nonleading_system, rules.coalesce_consecutive_users
1144 );
1145 }
1146 }
1147 eprintln!(
1148 "\naudited {total} templates ({flagged} flagged: {demote_only} demote-only, \
1149 {coalesce} coalescing); {} shape failures",
1150 failures.len()
1151 );
1152 for f in &failures {
1153 eprintln!(" FAIL {f}");
1154 }
1155 assert!(
1156 failures.is_empty(),
1157 "{} template/shape combinations did not render (probe insufficient or normalization insufficient)",
1158 failures.len()
1159 );
1160 }
1161
1162 #[test]
1170 fn test_render_long_conversation_does_not_overflow_stack() {
1171 let handle = std::thread::Builder::new()
1172 .stack_size(2 * 1024 * 1024)
1173 .spawn(|| {
1174 let template_string = concat!(
1175 "{%- set ns = namespace(items=[]) -%}",
1176 "{%- for m in messages -%}",
1177 "{%- set ns.items = ns.items + [m] -%}",
1178 "{%- endfor -%}",
1179 "COUNT={{ ns.items | length }}"
1180 );
1181 let chat_template: ChatTemplate =
1182 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
1183 .unwrap();
1184 let formatter =
1185 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[]))
1186 .unwrap();
1187
1188 let n = 3000;
1189 let messages: Vec<serde_json::Value> = (0..n)
1190 .map(|i| serde_json::json!({"role": "user", "content": format!("turn {i}")}))
1191 .collect();
1192 let request: NvCreateChatCompletionRequest =
1193 serde_json::from_value(serde_json::json!({
1194 "model": "test",
1195 "messages": messages,
1196 }))
1197 .unwrap();
1198
1199 let rendered = formatter.render(&request).unwrap();
1201 assert_eq!(rendered.trim(), format!("COUNT={n}"));
1202 })
1203 .unwrap();
1204 handle.join().unwrap();
1205 }
1206
1207 #[test]
1218 #[ignore]
1219 fn dump_gptoss_tool_prompt() {
1220 use super::tokcfg::ChatTemplate;
1221 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1222
1223 let path = std::env::var("GPTOSS_CHAT_TEMPLATE").expect(
1224 "set GPTOSS_CHAT_TEMPLATE to the tokenizer_config.json, chat_template.jinja, or model dir path",
1225 );
1226 let input_path = std::path::Path::new(&path);
1227 let file_path = if input_path.is_dir() {
1228 input_path.join("tokenizer_config.json")
1230 } else {
1231 input_path.to_path_buf()
1232 };
1233 let raw = std::fs::read_to_string(&file_path).expect("read chat template file");
1234 let template_string: String = match serde_json::from_str::<serde_json::Value>(&raw) {
1241 Ok(v) if v.get("chat_template").is_some() => v["chat_template"]
1242 .as_str()
1243 .expect("chat_template field must be a string")
1244 .to_string(),
1245 _ => {
1246 let sibling = std::path::Path::new(&path)
1247 .parent()
1248 .map(|d| d.join("chat_template.jinja"));
1249 match sibling {
1250 Some(p) if p.exists() => {
1251 eprintln!(
1252 "[info] {path} had no chat_template field; using {}",
1253 p.display()
1254 );
1255 std::fs::read_to_string(&p).expect("read sibling chat_template.jinja")
1256 }
1257 _ => raw,
1258 }
1259 }
1260 };
1261
1262 assert!(
1265 template_string.contains("{%") || template_string.contains("{{"),
1266 "resolved template has no Jinja tags — GPTOSS_CHAT_TEMPLATE ({path}) is probably \
1267 tokenizer_config.json with no chat_template field and no sibling chat_template.jinja. \
1268 Point it at the chat_template.jinja file."
1269 );
1270
1271 let chat_template: ChatTemplate =
1272 serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
1273 .unwrap();
1274
1275 let formatter =
1276 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1277
1278 let request: NvCreateChatCompletionRequest = serde_json::from_str(
1280 r#"{
1281 "model": "openai/gpt-oss-120b",
1282 "messages": [{"role":"user","content":"Search the repo for the string \"countHook\"."}],
1283 "tools": [
1284 {"type":"function","function":{"name":"grep","description":"search files","parameters":{"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}}},
1285 {"type":"function","function":{"name":"read","description":"read a file","parameters":{"type":"object","properties":{"filePath":{"type":"string"}},"required":["filePath"]}}}
1286 ]
1287 }"#,
1288 )
1289 .unwrap();
1290
1291 let rendered = formatter.render(&request).unwrap();
1292 eprintln!("================ RENDERED gpt-oss PROMPT (tools declared) ================");
1293 eprintln!("{rendered}");
1294 eprintln!("================ END RENDERED PROMPT ================");
1295 eprintln!("[diagnostics] does the rendered prompt contain…");
1296 for needle in [
1297 "commentary",
1298 "Calls to these tools",
1299 "functions",
1300 "# Tools",
1301 "<|channel|>",
1302 "constrain",
1303 "analysis",
1304 ] {
1305 eprintln!(
1306 " {:>22}: {}",
1307 format!("{needle:?}"),
1308 rendered.contains(needle)
1309 );
1310 }
1311 }
1312
1313 #[test]
1315 fn test_convert_media_url_to_placeholder_single_type() {
1316 let content_array = vec![
1317 serde_json::json!({"type": "text", "text": "Check this image:"}),
1318 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1319 serde_json::json!({"type": "text", "text": "What do you see?"}),
1320 ];
1321
1322 let conversions = &[("image_url", "image")];
1323 let result = convert_media_url_to_placeholder(&content_array, conversions);
1324
1325 assert_eq!(result.len(), 3);
1326 assert_eq!(result[0]["type"], "text");
1328 assert_eq!(result[0]["text"], "Check this image:");
1329 assert_eq!(result[1]["type"], "image");
1331 assert!(result[1].get("image_url").is_none());
1332 assert_eq!(result[2]["type"], "text");
1334 assert_eq!(result[2]["text"], "What do you see?");
1335 }
1336
1337 #[test]
1339 fn test_convert_media_url_to_placeholder_multiple_same_type() {
1340 let content_array = vec![
1341 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}}),
1342 serde_json::json!({"type": "text", "text": "vs"}),
1343 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}),
1344 ];
1345
1346 let conversions = &[("image_url", "image")];
1347 let result = convert_media_url_to_placeholder(&content_array, conversions);
1348
1349 assert_eq!(result.len(), 3);
1350 assert_eq!(result[0]["type"], "image");
1351 assert_eq!(result[1]["type"], "text");
1352 assert_eq!(result[2]["type"], "image");
1353 }
1354
1355 #[test]
1357 fn test_convert_media_url_to_placeholder_selective_conversion() {
1358 let content_array = vec![
1359 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1360 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1361 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1362 ];
1363
1364 let conversions = &[("image_url", "image")];
1366 let result = convert_media_url_to_placeholder(&content_array, conversions);
1367
1368 assert_eq!(result.len(), 3);
1369 assert_eq!(result[0]["type"], "audio_url");
1371 assert!(result[0].get("audio_url").is_some());
1372 assert_eq!(result[1]["type"], "video_url");
1373 assert!(result[1].get("video_url").is_some());
1374 assert_eq!(result[2]["type"], "image");
1376 assert!(result[2].get("image_url").is_none());
1377 }
1378
1379 #[test]
1381 fn test_convert_media_url_to_placeholder_multiple_types() {
1382 let content_array = vec![
1383 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1384 serde_json::json!({"type": "text", "text": "and listen to"}),
1385 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1386 serde_json::json!({"type": "text", "text": "and watch"}),
1387 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1388 ];
1389
1390 let conversions = &[
1392 ("image_url", "image"),
1393 ("audio_url", "audio"),
1394 ("video_url", "video"),
1395 ];
1396 let result = convert_media_url_to_placeholder(&content_array, conversions);
1397
1398 assert_eq!(result.len(), 5);
1399 assert_eq!(result[0]["type"], "image");
1400 assert!(result[0].get("image_url").is_none());
1401 assert_eq!(result[1]["type"], "text");
1402 assert_eq!(result[2]["type"], "audio");
1403 assert!(result[2].get("audio_url").is_none());
1404 assert_eq!(result[3]["type"], "text");
1405 assert_eq!(result[4]["type"], "video");
1406 assert!(result[4].get("video_url").is_none());
1407 }
1408
1409 #[test]
1411 fn test_convert_media_url_to_placeholder_no_conversions() {
1412 let content_array = vec![
1413 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1414 serde_json::json!({"type": "text", "text": "hello"}),
1415 ];
1416
1417 let conversions: &[(&str, &str)] = &[];
1418 let result = convert_media_url_to_placeholder(&content_array, conversions);
1419
1420 assert_eq!(result.len(), 2);
1421 assert_eq!(result[0]["type"], "image_url");
1423 assert!(result[0].get("image_url").is_some());
1424 assert_eq!(result[1]["type"], "text");
1425 }
1426
1427 #[test]
1430 fn test_default_media_type_conversions_only_converts_image_url() {
1431 let content_array = vec![
1432 serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1433 serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1434 serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1435 serde_json::json!({"type": "text", "text": "hello"}),
1436 ];
1437
1438 let result =
1440 convert_media_url_to_placeholder(&content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
1441
1442 assert_eq!(result.len(), 4);
1443
1444 assert_eq!(result[0]["type"], "image");
1446 assert!(result[0].get("image_url").is_none());
1447
1448 assert_eq!(result[1]["type"], "video");
1450 assert!(result[1].get("video_url").is_none());
1451
1452 assert_eq!(result[2]["type"], "audio");
1454 assert!(result[2].get("audio_url").is_none());
1455
1456 assert_eq!(result[3]["type"], "text");
1458 assert_eq!(result[3]["text"], "hello");
1459 }
1460
1461 #[test]
1462 fn test_may_be_fix_tool_schema_missing_type_and_properties() {
1463 let json_str = r#"{
1464 "model": "gpt-4o",
1465 "messages": [],
1466 "tools": [
1467 {
1468 "type": "function",
1469 "function": {
1470 "name": "get_weather",
1471 "description": "Get the current weather in a given location",
1472 "parameters": {},
1473 "strict": null
1474 }
1475 }
1476 ]
1477 }"#;
1478
1479 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1480 let tools = serde_json::to_value(request.tools()).unwrap();
1481
1482 assert!(tools[0]["function"]["parameters"]["type"] == "object");
1483 assert!(
1484 tools[0]["function"]["parameters"]["properties"]
1485 == serde_json::Value::Object(Default::default())
1486 );
1487 }
1488
1489 #[test]
1490 fn test_may_be_fix_tool_schema_missing_type() {
1491 let json_str = r#"{
1492 "model": "gpt-4o",
1493 "messages": [],
1494 "tools": [
1495 {
1496 "type": "function",
1497 "function": {
1498 "name": "get_weather",
1499 "description": "Get the current weather in a given location",
1500 "parameters": {
1501 "properties": {
1502 "location": {
1503 "type": "string",
1504 "description": "City and state, e.g., 'San Francisco, CA'"
1505 }
1506 }
1507 },
1508 "strict": null
1509 }
1510 }
1511 ]
1512 }"#;
1513 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1514
1515 let tools = serde_json::to_value(request.tools()).unwrap();
1516
1517 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
1518
1519 let mut expected_properties = serde_json::Map::new();
1520 let mut location = serde_json::Map::new();
1521 location.insert(
1522 "type".to_string(),
1523 serde_json::Value::String("string".to_string()),
1524 );
1525 location.insert(
1526 "description".to_string(),
1527 serde_json::Value::String("City and state, e.g., 'San Francisco, CA'".to_string()),
1528 );
1529 expected_properties.insert("location".to_string(), serde_json::Value::Object(location));
1530
1531 assert_eq!(
1532 tools[0]["function"]["parameters"]["properties"],
1533 serde_json::Value::Object(expected_properties)
1534 );
1535 }
1536
1537 #[test]
1538 fn test_may_be_fix_tool_schema_missing_properties() {
1539 let json_str = r#"{
1540 "model": "gpt-4o",
1541 "messages": [],
1542 "tools": [
1543 {
1544 "type": "function",
1545 "function": {
1546 "name": "get_weather",
1547 "description": "Get the current weather in a given location",
1548 "parameters": {"type": "object"},
1549 "strict": null
1550 }
1551 }
1552 ]
1553 }"#;
1554
1555 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1556 let tools = serde_json::to_value(request.tools()).unwrap();
1557
1558 assert_eq!(
1559 tools[0]["function"]["parameters"]["properties"],
1560 serde_json::Value::Object(Default::default())
1561 );
1562 assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
1563 }
1564
1565 #[test]
1566 fn test_may_be_fix_tool_schema_missing_description() {
1567 let json_str = r#"{
1571 "model": "gpt-4o",
1572 "messages": [],
1573 "tools": [
1574 {
1575 "type": "function",
1576 "function": {
1577 "name": "noop",
1578 "parameters": {
1579 "type": "object",
1580 "properties": { "x": { "type": "string" } },
1581 "required": ["x"],
1582 "additionalProperties": false
1583 },
1584 "strict": null
1585 }
1586 }
1587 ]
1588 }"#;
1589
1590 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1591 let tools = serde_json::to_value(request.tools()).unwrap();
1592
1593 assert_eq!(
1594 tools[0]["function"]["description"],
1595 serde_json::Value::String(String::new())
1596 );
1597 }
1598
1599 #[test]
1600 fn test_may_be_fix_tool_schema_null_description() {
1601 let json_str = r#"{
1603 "model": "gpt-4o",
1604 "messages": [],
1605 "tools": [
1606 {
1607 "type": "function",
1608 "function": {
1609 "name": "noop",
1610 "description": null,
1611 "parameters": {"type": "object", "properties": {}},
1612 "strict": null
1613 }
1614 }
1615 ]
1616 }"#;
1617
1618 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1619 let tools = serde_json::to_value(request.tools()).unwrap();
1620
1621 assert_eq!(
1622 tools[0]["function"]["description"],
1623 serde_json::Value::String(String::new())
1624 );
1625 }
1626
1627 #[test]
1628 fn test_may_be_fix_tool_schema_preserves_description() {
1629 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", "properties": {}},
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"]["description"],
1651 "Get the current weather in a given location"
1652 );
1653 }
1654
1655 #[test]
1657 fn test_may_be_fix_msg_content_user_multipart() {
1658 let json_str = r#"{
1659 "model": "gpt-4o",
1660 "messages": [
1661 {
1662 "role": "user",
1663 "content": [
1664 {"type": "text", "text": "part 1"},
1665 {"type": "text", "text": "part 2"}
1666 ]
1667 }
1668 ]
1669 }"#;
1670
1671 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1672 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1673
1674 let messages =
1676 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1677
1678 assert_eq!(
1680 messages[0]["content"],
1681 serde_json::Value::String("part 1\npart 2".to_string())
1682 );
1683 }
1684
1685 #[test]
1688 fn test_may_be_fix_msg_content_mixed_messages() {
1689 let json_str = r#"{
1690 "model": "gpt-4o",
1691 "messages": [
1692 {
1693 "role": "system",
1694 "content": "You are a helpful assistant"
1695 },
1696 {
1697 "role": "user",
1698 "content": [
1699 {"type": "text", "text": "Hello"},
1700 {"type": "text", "text": "World"}
1701 ]
1702 },
1703 {
1704 "role": "assistant",
1705 "content": "Hi there!"
1706 },
1707 {
1708 "role": "user",
1709 "content": [
1710 {"type": "text", "text": "Another"},
1711 {"type": "text", "text": "multi-part"},
1712 {"type": "text", "text": "message"}
1713 ]
1714 }
1715 ]
1716 }"#;
1717
1718 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1719 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1720
1721 let messages =
1723 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1724
1725 assert_eq!(
1727 messages[0]["content"],
1728 serde_json::Value::String("You are a helpful assistant".to_string())
1729 );
1730
1731 assert_eq!(
1733 messages[1]["content"],
1734 serde_json::Value::String("Hello\nWorld".to_string())
1735 );
1736
1737 assert_eq!(
1739 messages[2]["content"],
1740 serde_json::Value::String("Hi there!".to_string())
1741 );
1742
1743 assert_eq!(
1745 messages[3]["content"],
1746 serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
1747 );
1748 }
1749
1750 #[test]
1752 fn test_may_be_fix_msg_content_empty_array() {
1753 let json_str = r#"{
1754 "model": "gpt-4o",
1755 "messages": [
1756 {
1757 "role": "user",
1758 "content": []
1759 }
1760 ]
1761 }"#;
1762
1763 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1764 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1765
1766 let messages =
1768 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1769
1770 assert!(messages[0]["content"].is_array());
1772 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1773 }
1774
1775 #[test]
1782 fn test_may_be_fix_msg_content_empty_array_with_placeholder_template() {
1783 let json_str = r#"{
1784 "model": "phi-3-vision",
1785 "messages": [
1786 {
1787 "role": "user",
1788 "content": []
1789 }
1790 ]
1791 }"#;
1792
1793 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1794 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1795
1796 let messages = serde_json::to_value(may_be_fix_msg_content(
1799 messages_raw,
1800 false,
1801 Some("<|image_{n}|>"),
1802 ))
1803 .unwrap();
1804
1805 assert!(
1806 messages[0]["content"].is_array(),
1807 "empty array should be preserved as `[]`, not flattened to `\"\"`"
1808 );
1809 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1810 }
1811
1812 #[test]
1814 fn test_may_be_fix_msg_content_single_text() {
1815 let json_str = r#"{
1816 "model": "gpt-4o",
1817 "messages": [
1818 {
1819 "role": "user",
1820 "content": "Simple text message"
1821 }
1822 ]
1823 }"#;
1824
1825 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1826 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1827
1828 let messages =
1830 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1831
1832 assert_eq!(
1834 messages[0]["content"],
1835 serde_json::Value::String("Simple text message".to_string())
1836 );
1837 }
1838
1839 #[test]
1842 fn test_may_be_fix_msg_content_mixed_types() {
1843 let json_str = r#"{
1844 "model": "gpt-4o",
1845 "messages": [
1846 {
1847 "role": "user",
1848 "content": [
1849 {"type": "text", "text": "Check this image:"},
1850 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
1851 {"type": "text", "text": "What do you see?"}
1852 ]
1853 }
1854 ]
1855 }"#;
1856
1857 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1858 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1859
1860 let messages =
1862 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1863
1864 assert!(messages[0]["content"].is_array());
1867 let content_array = messages[0]["content"].as_array().unwrap();
1868 assert_eq!(content_array.len(), 3);
1869 assert_eq!(content_array[0]["type"], "text");
1870 assert_eq!(content_array[1]["type"], "image");
1871 assert!(content_array[1].get("image_url").is_none());
1872 assert_eq!(content_array[2]["type"], "text");
1873 }
1874
1875 #[test]
1881 fn test_may_be_fix_msg_content_flattens_phi3_style() {
1882 let json_str = r#"{
1883 "model": "phi-3-vision",
1884 "messages": [
1885 {
1886 "role": "user",
1887 "content": [
1888 {"type": "text", "text": "First "},
1889 {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}},
1890 {"type": "text", "text": " then "},
1891 {"type": "image_url", "image_url": {"url": "https://example.com/b.jpg"}},
1892 {"type": "text", "text": "?"}
1893 ]
1894 }
1895 ]
1896 }"#;
1897 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1898 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1899
1900 let messages = serde_json::to_value(may_be_fix_msg_content(
1901 messages_raw,
1902 false,
1903 Some("<|image_{n}|>"),
1904 ))
1905 .unwrap();
1906
1907 let content = messages[0]["content"].as_str().expect("content flattened");
1908 assert_eq!(content, "First <|image_1|> then <|image_2|>?");
1909 }
1910
1911 #[test]
1913 fn test_may_be_fix_msg_content_flattens_llava_style() {
1914 let json_str = r#"{
1915 "model": "llava-1.5-7b-hf",
1916 "messages": [
1917 {
1918 "role": "user",
1919 "content": [
1920 {"type": "text", "text": "Describe: "},
1921 {"type": "image_url", "image_url": {"url": "https://example.com/x.jpg"}}
1922 ]
1923 }
1924 ]
1925 }"#;
1926 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1927 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1928
1929 let messages =
1930 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some("<image>")))
1931 .unwrap();
1932
1933 let content = messages[0]["content"].as_str().expect("content flattened");
1934 assert_eq!(content, "Describe: <image>");
1935 }
1936
1937 #[test]
1943 fn test_may_be_fix_msg_content_flattens_empty_placeholder() {
1944 let json_str = r#"{
1945 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1946 "messages": [
1947 {
1948 "role": "user",
1949 "content": [
1950 {"type": "text", "text": "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"},
1951 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1952 ]
1953 }
1954 ]
1955 }"#;
1956 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1957 let messages_raw = serde_json::to_value(request.messages()).unwrap();
1958
1959 let messages =
1960 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some(""))).unwrap();
1961
1962 let content = messages[0]["content"].as_str().expect("content flattened");
1963 assert_eq!(
1964 content,
1965 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
1966 );
1967 }
1968
1969 #[test]
1976 fn test_render_nemotron_parse_passthrough() {
1977 use super::super::tokcfg::ChatTemplate;
1978 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1979
1980 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1981 "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}"
1982 }))
1983 .unwrap();
1984 let formatter =
1985 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1986
1987 for prompt in [
1988 "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>",
1989 "</s><s><predict_bbox><predict_classes><output_markdown><predict_text_in_pic>",
1990 ] {
1991 let request: NvCreateChatCompletionRequest =
1992 serde_json::from_value(serde_json::json!({
1993 "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1994 "messages": [{
1995 "role": "user",
1996 "content": [
1997 {"type": "text", "text": prompt},
1998 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1999 ]
2000 }]
2001 }))
2002 .unwrap();
2003
2004 let rendered = formatter.render(&request).unwrap();
2005 assert_eq!(
2006 rendered, prompt,
2007 "rendered prompt must be the control tokens only, with no JSON-serialized image array"
2008 );
2009 }
2010 }
2011
2012 #[test]
2015 fn test_may_be_fix_msg_content_non_text_only() {
2016 let json_str = r#"{
2017 "model": "gpt-4o",
2018 "messages": [
2019 {
2020 "role": "user",
2021 "content": [
2022 {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}},
2023 {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}
2024 ]
2025 }
2026 ]
2027 }"#;
2028
2029 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2030 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2031
2032 let messages =
2034 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2035
2036 assert!(messages[0]["content"].is_array());
2038 let content_array = messages[0]["content"].as_array().unwrap();
2039 assert_eq!(content_array.len(), 2);
2040 assert_eq!(content_array[0]["type"], "image");
2041 assert_eq!(content_array[1]["type"], "image");
2042 }
2043
2044 #[test]
2045 fn test_none_tools_safe_for_all_templates() {
2046 use super::tokcfg::ChatTemplate;
2047 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2048
2049 let length_template = r#"
2053{%- if tools is iterable and tools | length > 0 %}
2054Tools available: {{ tools | length }}
2055{%- else %}
2056No tools
2057{%- endif %}
2058"#;
2059
2060 let no_tool_template = r#"
2063{%- if tools is not none %}
2064TOOL MODE
2065{%- else %}
2066NORMAL MODE
2067{%- endif %}
2068"#;
2069
2070 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2071 "chat_template": [
2072 {"safe_length": length_template},
2073 {"no_tool": no_tool_template}
2074 ]
2075 }))
2076 .unwrap();
2077
2078 let formatter =
2079 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2080
2081 let ctx = context! { tools => Option::<Value>::None };
2082
2083 let result1 = formatter
2084 .env
2085 .get_template("safe_length")
2086 .unwrap()
2087 .render(&ctx);
2088 println!("Safe length template with no tools => None: {:?}", result1);
2089 assert!(
2090 result1.is_ok(),
2091 "Jinja template with and conditional and length filter should handle None: {:?}",
2092 result1
2093 );
2094 assert!(
2095 result1.unwrap().contains("No tools"),
2096 "Should show 'No tools'"
2097 );
2098
2099 let result2 = formatter.env.get_template("no_tool").unwrap().render(&ctx);
2100 println!("Default template with no tools => None: {:?}", result2);
2101 assert!(
2102 result2.is_ok(),
2103 "Jinja template with if tools is not none conditional should handle None: {:?}",
2104 result2
2105 );
2106 assert!(result2.unwrap().contains("NORMAL MODE"));
2107 }
2108
2109 #[test]
2111 fn test_may_be_fix_msg_content_multiple_content_types() {
2112 let json_str = r#"{
2114 "model": "gpt-4o",
2115 "messages": [
2116 {
2117 "role": "user",
2118 "content": [
2119 {"type": "text", "text": "Listen to this:"},
2120 {"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}},
2121 {"type": "text", "text": "And look at:"},
2122 {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
2123 {"type": "text", "text": "What do you think?"}
2124 ]
2125 }
2126 ]
2127 }"#;
2128
2129 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2130 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2131 let messages =
2132 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2133
2134 assert!(messages[0]["content"].is_array());
2136 let content_array = messages[0]["content"].as_array().unwrap();
2137 assert_eq!(content_array.len(), 5);
2138 assert_eq!(content_array[0]["type"], "text");
2139 assert_eq!(content_array[1]["type"], "audio");
2140 assert_eq!(content_array[2]["type"], "text");
2141 assert_eq!(content_array[3]["type"], "image");
2142 assert_eq!(content_array[4]["type"], "text");
2143
2144 let json_str = r#"{
2146 "model": "gpt-4o",
2147 "messages": [
2148 {
2149 "role": "user",
2150 "content": [
2151 {"type": "text", "text": "Check this:"},
2152 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
2153 {"type": "text", "text": "Interesting?"}
2154 ]
2155 }
2156 ]
2157 }"#;
2158
2159 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2160 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2161 let messages =
2162 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2163
2164 assert!(messages[0]["content"].is_array());
2166 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
2167 }
2168
2169 #[test]
2170 fn test_normalize_tool_arguments_tojson() {
2171 let tmpl = r#"{{ messages[0].tool_calls[0].function.arguments | tojson }}"#;
2172
2173 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2175 "role": "assistant",
2176 "tool_calls": [{
2177 "type": "function",
2178 "function": {
2179 "name": "get_current_weather",
2180 "arguments": "{\"format\":\"celsius\",\"location\":\"San Francisco, CA\"}"
2181 }
2182 }]
2183 })]);
2184
2185 normalize_tool_calls_arguments_in_messages(&mut messages);
2186
2187 let mut env = Environment::new();
2188 env.add_filter("tojson", super::super::tokcfg::tojson);
2189 env.add_template("t", tmpl).unwrap();
2190 let out = env
2191 .get_template("t")
2192 .unwrap()
2193 .render(context! { messages => messages.as_array().unwrap() })
2194 .unwrap();
2195
2196 assert_eq!(
2199 out,
2200 r#"{"format": "celsius", "location": "San Francisco, CA"}"#
2201 );
2202 }
2203
2204 #[test]
2205 fn test_normalize_tool_arguments_items_loop() {
2206 let tmpl = r#"{% for k, v in messages[0].tool_calls[0].function.arguments|items %}{{k}}={{v}};{% endfor %}"#;
2207
2208 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2209 "role": "assistant",
2210 "tool_calls": [{
2211 "type": "function",
2212 "function": {
2213 "name": "f",
2214 "arguments": "{\"a\":1,\"b\":\"x\"}"
2215 }
2216 }]
2217 })]);
2218
2219 normalize_tool_calls_arguments_in_messages(&mut messages);
2220
2221 let mut env = Environment::new();
2222 env.add_template("t", tmpl).unwrap();
2223 let out = env
2224 .get_template("t")
2225 .unwrap()
2226 .render(context! { messages => messages.as_array().unwrap() })
2227 .unwrap();
2228
2229 assert!(out == "a=1;b=x;" || out == "b=x;a=1;");
2230 }
2231
2232 #[test]
2233 fn test_normalize_tool_arguments_legacy_function_call() {
2234 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2236 "role": "assistant",
2237 "function_call": {
2238 "name": "get_weather",
2239 "arguments": "{\"location\":\"NYC\"}"
2240 }
2241 })]);
2242
2243 normalize_function_call_arguments_in_messages(&mut messages);
2244
2245 assert_eq!(
2246 messages[0]["function_call"]["arguments"],
2247 serde_json::json!({"location": "NYC"})
2248 );
2249 }
2250
2251 #[test]
2252 fn test_normalize_tool_arguments_malformed_json_passthrough() {
2253 let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2255 "role": "assistant",
2256 "tool_calls": [{
2257 "type": "function",
2258 "function": {
2259 "name": "f",
2260 "arguments": "not valid json at all"
2261 }
2262 }]
2263 })]);
2264
2265 normalize_tool_calls_arguments_in_messages(&mut messages);
2266
2267 assert_eq!(
2268 messages[0]["tool_calls"][0]["function"]["arguments"],
2269 serde_json::Value::String("not valid json at all".to_string())
2270 );
2271 }
2272
2273 #[test]
2274 fn test_normalize_tool_arguments_with_multimodal_content() {
2275 let json_str = r#"{
2276 "model": "gpt-4o",
2277 "messages": [
2278 {
2279 "role": "user",
2280 "content": [
2281 {"type": "text", "text": "Check this:"},
2282 {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
2283 {"type": "text", "text": "Interesting?"}
2284 ]
2285 },
2286 {
2287 "role": "assistant",
2288 "tool_calls": [{
2289 "id": "call_123",
2290 "type": "function",
2291 "function": {
2292 "name": "analyze_video",
2293 "arguments": "{\"url\":\"https://example.com/vid.mp4\",\"format\":\"mp4\"}"
2294 }
2295 }]
2296 }
2297 ]
2298 }"#;
2299
2300 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2301 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2302
2303 let mut messages =
2305 serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2306
2307 normalize_tool_calls_arguments_in_messages(&mut messages);
2308
2309 assert!(messages[0]["content"].is_array());
2311 assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
2312
2313 assert!(messages[1]["tool_calls"][0]["function"]["arguments"].is_object());
2315 assert_eq!(
2316 messages[1]["tool_calls"][0]["function"]["arguments"]["url"],
2317 "https://example.com/vid.mp4"
2318 );
2319 }
2320
2321 #[test]
2323 fn test_may_be_fix_msg_content_string_to_array() {
2324 let json_str = r#"{
2325 "model": "gpt-4o",
2326 "messages": [
2327 {
2328 "role": "user",
2329 "content": "Hello, how are you?"
2330 }
2331 ]
2332 }"#;
2333
2334 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2335 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2336
2337 let messages =
2339 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
2340
2341 assert!(messages[0]["content"].is_array());
2343 let content_array = messages[0]["content"].as_array().unwrap();
2344 assert_eq!(content_array.len(), 1);
2345 assert_eq!(content_array[0]["type"], "text");
2346 assert_eq!(content_array[0]["text"], "Hello, how are you?");
2347 }
2348
2349 #[test]
2351 fn test_may_be_fix_msg_content_array_preserved_with_multimodal() {
2352 let json_str = r#"{
2353 "model": "gpt-4o",
2354 "messages": [
2355 {
2356 "role": "user",
2357 "content": [
2358 {"type": "text", "text": "part 1"},
2359 {"type": "text", "text": "part 2"}
2360 ]
2361 }
2362 ]
2363 }"#;
2364
2365 let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2366 let messages_raw = serde_json::to_value(request.messages()).unwrap();
2367
2368 let messages =
2370 serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
2371
2372 assert!(messages[0]["content"].is_array());
2374 let content_array = messages[0]["content"].as_array().unwrap();
2375 assert_eq!(content_array.len(), 2);
2376 assert_eq!(content_array[0]["text"], "part 1");
2377 assert_eq!(content_array[1]["text"], "part 2");
2378 }
2379
2380 fn user() -> Msg {
2381 Msg::User(Default::default())
2382 }
2383 fn tool() -> Msg {
2384 Msg::Tool(Default::default())
2385 }
2386
2387 fn dummy_state(messages: Vec<Msg>) -> NvCreateChatCompletionRequest {
2388 let json = serde_json::json!({
2389 "model": "test-model",
2390 "messages": messages
2391 });
2392 serde_json::from_value(json).unwrap()
2393 }
2394
2395 #[test]
2396 fn add_after_user() {
2397 let s = dummy_state(vec![user()]);
2398 assert!(s.should_add_generation_prompt());
2399 }
2400
2401 #[test]
2402 fn add_after_tool() {
2403 let s = dummy_state(vec![tool()]);
2404 assert!(s.should_add_generation_prompt());
2405 }
2406
2407 #[test]
2408 fn add_when_empty() {
2409 let s = dummy_state(vec![]);
2410 assert!(s.should_add_generation_prompt());
2411 }
2412
2413 fn tool_aware_formatter(
2415 exclude_tools_when_tool_choice_none: bool,
2416 ) -> HfTokenizerConfigJsonFormatter {
2417 let template = r#"
2418{%- if tools is iterable and tools | length > 0 %}
2419TOOL_MODE tools={{ tools | length }}
2420{%- else %}
2421NORMAL_MODE
2422{%- endif %}
2423{{ messages[0].content }}"#;
2424
2425 let chat_template: super::tokcfg::ChatTemplate =
2426 serde_json::from_value(serde_json::json!({ "chat_template": template })).unwrap();
2427
2428 HfTokenizerConfigJsonFormatter::with_options(
2429 chat_template,
2430 ContextMixins::new(&[]),
2431 exclude_tools_when_tool_choice_none,
2432 )
2433 .unwrap()
2434 }
2435
2436 fn gemma4_tool_template_for_tests() -> &'static str {
2437 r#"
2438{{ bos_token }}
2439{%- set loop_messages = messages -%}
2440{%- set ns_turn = namespace(last_user_idx=-1) -%}
2441{%- for i in range(loop_messages | length) -%}
2442 {%- if loop_messages[i]['role'] == 'user' -%}
2443 {%- set ns_turn.last_user_idx = i -%}
2444 {%- endif -%}
2445{%- endfor -%}
2446{%- for message in loop_messages -%}
2447 {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
2448 {{- '<|turn>' + role + '\n' }}
2449
2450 {%- if message.get('reasoning') and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
2451 {{- '<|channel>thought\n' + message['reasoning'] + '\n<channel|>'}}
2452 {%- endif -%}
2453
2454 {%- if message['tool_calls'] -%}
2455 {%- for tool_call in message['tool_calls'] -%}
2456 {%- set function = tool_call['function'] -%}
2457 {{- '<|tool_call>call:' + function['name'] + '{' -}}
2458 {%- if function['arguments'] is mapping -%}
2459 {%- set ns_args = namespace(found_first=false) -%}
2460 {%- for key, value in function['arguments'] | dictsort -%}
2461 {%- if ns_args.found_first %},{% endif -%}
2462 {%- set ns_args.found_first = true -%}
2463 {{- key -}}:{{- value -}}
2464 {%- endfor -%}
2465 {%- elif function['arguments'] is string -%}
2466 {{- function['arguments'] -}}
2467 {%- endif -%}
2468 {{- '}<tool_call|>' -}}
2469 {%- endfor -%}
2470 {%- endif -%}
2471
2472 {%- if message['content'] is string -%}
2473 {{- message['content'] -}}
2474 {%- endif -%}
2475 {{- '<turn|>\n' -}}
2476{%- endfor -%}
2477"#
2478 }
2479
2480 fn make_gemma4_tool_formatter_for_tests() -> HfTokenizerConfigJsonFormatter {
2481 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2482 "chat_template": gemma4_tool_template_for_tests()
2483 }))
2484 .unwrap();
2485 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2486 }
2487
2488 fn request_with_tool_choice(tool_choice: &str) -> NvCreateChatCompletionRequest {
2490 serde_json::from_value(serde_json::json!({
2491 "model": "test",
2492 "messages": [{"role": "user", "content": "hello"}],
2493 "tools": [{
2494 "type": "function",
2495 "function": {
2496 "name": "get_weather",
2497 "description": "Get weather",
2498 "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}
2499 }
2500 }],
2501 "tool_choice": tool_choice
2502 }))
2503 .unwrap()
2504 }
2505
2506 #[test]
2507 fn test_exclude_tools_strips_when_tool_choice_none() {
2508 let formatter = tool_aware_formatter(true);
2509 let request = request_with_tool_choice("none");
2510 let result = formatter.render(&request).unwrap();
2511 assert!(
2512 result.contains("NORMAL_MODE"),
2513 "With exclude_tools=true and tool_choice=none, tools should be stripped. Got: {}",
2514 result
2515 );
2516 }
2517
2518 #[test]
2519 fn test_exclude_tools_keeps_when_tool_choice_auto() {
2520 let formatter = tool_aware_formatter(true);
2521 let request = request_with_tool_choice("auto");
2522 let result = formatter.render(&request).unwrap();
2523 assert!(
2524 result.contains("TOOL_MODE"),
2525 "With tool_choice=auto, tools should be included. Got: {}",
2526 result
2527 );
2528 }
2529
2530 #[test]
2531 fn test_no_exclude_tools_keeps_when_tool_choice_none() {
2532 let formatter = tool_aware_formatter(false);
2533 let request = request_with_tool_choice("none");
2534 let result = formatter.render(&request).unwrap();
2535 assert!(
2536 result.contains("TOOL_MODE"),
2537 "With exclude_tools=false and tool_choice=none, tools should NOT be stripped. Got: {}",
2538 result
2539 );
2540 }
2541
2542 #[test]
2543 fn test_inject_reasoning_content_segments_with_tool_calls() {
2544 let mut messages = serde_json::json!([
2546 {
2547 "role": "user",
2548 "content": "What is sqrt(144) and sqrt(256)?"
2549 },
2550 {
2551 "role": "assistant",
2552 "content": "Let me calculate those.",
2553 "reasoning_content": ["I need to compute sqrt(144)", "Now sqrt(256)", ""],
2554 "tool_calls": [
2555 {
2556 "id": "call_0",
2557 "type": "function",
2558 "function": {
2559 "name": "calculator",
2560 "arguments": "{\"expr\": \"sqrt(144)\"}"
2561 }
2562 },
2563 {
2564 "id": "call_1",
2565 "type": "function",
2566 "function": {
2567 "name": "calculator",
2568 "arguments": "{\"expr\": \"sqrt(256)\"}"
2569 }
2570 }
2571 ]
2572 }
2573 ]);
2574
2575 inject_reasoning_content_into_messages(&mut messages);
2576
2577 let assistant = &messages[1];
2578
2579 assert!(
2581 assistant.get("reasoning_content").is_none(),
2582 "reasoning_content should be removed after injection"
2583 );
2584
2585 let content = assistant["content"].as_str().unwrap();
2587 assert!(
2588 content.starts_with("<think>I need to compute sqrt(144)</think>"),
2589 "content should start with first reasoning segment, got: {}",
2590 content
2591 );
2592 assert!(
2593 content.contains("<think>Now sqrt(256)</think>"),
2594 "content should contain second reasoning segment"
2595 );
2596 assert!(
2598 !content.contains("<think></think>"),
2599 "empty segments should be skipped"
2600 );
2601 assert!(
2603 content.ends_with("Let me calculate those."),
2604 "original content should be at the end, got: {}",
2605 content
2606 );
2607
2608 assert!(assistant.get("tool_calls").is_some());
2610 assert_eq!(assistant["tool_calls"].as_array().unwrap().len(), 2);
2611 }
2612
2613 #[test]
2614 fn test_gemma4_template_renders_reasoning_content_segments_around_tool_calls() {
2615 let formatter = make_gemma4_tool_formatter_for_tests();
2616 assert!(
2617 formatter.tool_use_template_handles_reasoning,
2618 "Gemma4 template adaptation should make reasoning_content native"
2619 );
2620
2621 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2622 "model": "gemma4-test",
2623 "messages": [
2624 {"role": "user", "content": "inspect two things"},
2625 {
2626 "role": "assistant",
2627 "content": null,
2628 "reasoning_content": [
2629 "Think before the first call.",
2630 "Think before the second call.",
2631 "Think after both calls."
2632 ],
2633 "tool_calls": [
2634 {
2635 "id": "call_0",
2636 "type": "function",
2637 "function": {
2638 "name": "first_tool",
2639 "arguments": "{\"path\":\".\"}"
2640 }
2641 },
2642 {
2643 "id": "call_1",
2644 "type": "function",
2645 "function": {
2646 "name": "second_tool",
2647 "arguments": "{\"path\":\"/tmp\"}"
2648 }
2649 }
2650 ]
2651 }
2652 ]
2653 }))
2654 .unwrap();
2655
2656 let rendered = formatter.render(&request).unwrap();
2657
2658 let expected = concat!(
2659 "<|channel>thought\nThink before the first call.\n<channel|>",
2660 "<|tool_call>call:first_tool{path:.}<tool_call|>",
2661 "<|channel>thought\nThink before the second call.\n<channel|>",
2662 "<|tool_call>call:second_tool{path:/tmp}<tool_call|>",
2663 "<|channel>thought\nThink after both calls.\n<channel|>"
2664 );
2665 assert!(
2666 rendered.contains(expected),
2667 "Gemma4 reasoning segments should stay adjacent to their tool calls, got: {rendered}"
2668 );
2669 assert!(!rendered.contains("<think>"));
2670 assert!(!rendered.contains("reasoning_content"));
2671 }
2672
2673 #[test]
2674 fn test_gemma4_template_renders_reasoning_content_without_tool_calls() {
2675 let formatter = make_gemma4_tool_formatter_for_tests();
2676 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2677 "model": "gemma4-test",
2678 "messages": [
2679 {"role": "user", "content": "answer directly"},
2680 {
2681 "role": "assistant",
2682 "content": "Direct answer.",
2683 "reasoning_content": "Private thought."
2684 }
2685 ]
2686 }))
2687 .unwrap();
2688
2689 let rendered = formatter.render(&request).unwrap();
2690
2691 assert!(
2692 rendered.contains("<|channel>thought\nPrivate thought.\n<channel|>Direct answer."),
2693 "Gemma4 reasoning_content should render in the thought channel, got: {rendered}"
2694 );
2695 assert!(!rendered.contains("<think>"));
2696 assert!(!rendered.contains("reasoning_content"));
2697 }
2698
2699 #[test]
2705 fn test_reasoning_flag_is_per_template_not_global() {
2706 const PLAIN_DEFAULT: &str = "{{ bos_token }}{%- for message in messages -%}\
2709 {{ message['role'] }}: {{ message['content'] }}\n{%- endfor -%}";
2710
2711 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2712 "chat_template": [
2713 {"default": PLAIN_DEFAULT},
2714 {"tool_use": gemma4_tool_template_for_tests()},
2715 ]
2716 }))
2717 .unwrap();
2718 let formatter =
2719 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2720
2721 assert!(
2725 formatter.tool_use_template_handles_reasoning,
2726 "adapted gemma4 tool_use template should handle reasoning natively"
2727 );
2728 assert!(
2729 !formatter.default_template_handles_reasoning,
2730 "plain default template does not reference reasoning_content"
2731 );
2732
2733 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2736 "model": "gemma4-test",
2737 "messages": [
2738 {"role": "user", "content": "answer directly"},
2739 {
2740 "role": "assistant",
2741 "content": "Direct answer.",
2742 "reasoning_content": "Private thought."
2743 }
2744 ]
2745 }))
2746 .unwrap();
2747
2748 let rendered = formatter.render(&request).unwrap();
2749 assert!(
2750 rendered.contains("<think>Private thought.</think>Direct answer."),
2751 "reasoning must be injected on the no-tool default path, got: {rendered}"
2752 );
2753 }
2754
2755 #[test]
2756 fn test_inject_reasoning_content_text_variant() {
2757 let mut messages = serde_json::json!([
2758 {
2759 "role": "assistant",
2760 "content": "The answer is 42.",
2761 "reasoning_content": "Let me think about this carefully."
2762 }
2763 ]);
2764
2765 inject_reasoning_content_into_messages(&mut messages);
2766
2767 let assistant = &messages[0];
2768 assert!(assistant.get("reasoning_content").is_none());
2769 let content = assistant["content"].as_str().unwrap();
2770 assert_eq!(
2771 content,
2772 "<think>Let me think about this carefully.</think>The answer is 42."
2773 );
2774 }
2775
2776 #[test]
2777 fn test_inject_reasoning_content_null_content() {
2778 let mut messages = serde_json::json!([
2780 {
2781 "role": "assistant",
2782 "content": null,
2783 "reasoning_content": "Thinking...",
2784 "tool_calls": [{"id": "call_0", "type": "function", "function": {"name": "f", "arguments": "{}"}}]
2785 }
2786 ]);
2787
2788 inject_reasoning_content_into_messages(&mut messages);
2789
2790 let content = messages[0]["content"].as_str().unwrap();
2791 assert_eq!(content, "<think>Thinking...</think>");
2792 assert!(messages[0].get("reasoning_content").is_none());
2793 }
2794
2795 #[test]
2796 fn test_inject_reasoning_content_skips_non_assistant() {
2797 let mut messages = serde_json::json!([
2798 {
2799 "role": "user",
2800 "content": "hello",
2801 "reasoning_content": "should not be touched"
2802 }
2803 ]);
2804
2805 inject_reasoning_content_into_messages(&mut messages);
2806
2807 assert!(messages[0].get("reasoning_content").is_some());
2809 }
2810
2811 fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
2813 use super::tokcfg::ChatTemplate;
2814 use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2815
2816 let template = r#"{%- for message in messages %}{{ message.role }}: {{ message.content }}
2819{%- endfor %}
2820{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2821
2822 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2823 "chat_template": template
2824 }))
2825 .unwrap();
2826
2827 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2828 }
2829
2830 #[test]
2833 fn test_reasoning_content_text_roundtrip_render() {
2834 use super::OAIPromptFormatter;
2835 let formatter = make_test_formatter();
2836
2837 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2838 "model": "test-model",
2839 "messages": [
2840 {"role": "user", "content": "What is sqrt(144)?"},
2841 {
2842 "role": "assistant",
2843 "content": "The answer is 12.",
2844 "reasoning_content": "I need to compute the square root of 144."
2845 },
2846 {"role": "user", "content": "Are you sure?"}
2847 ]
2848 }))
2849 .unwrap();
2850
2851 let rendered = formatter.render(&request).unwrap();
2852
2853 assert!(
2854 rendered.contains("<think>I need to compute the square root of 144.</think>"),
2855 "reasoning_content must appear as <think> block, got: {}",
2856 rendered
2857 );
2858 assert!(
2859 rendered.contains("The answer is 12."),
2860 "original content must be preserved"
2861 );
2862 assert!(
2863 !rendered.contains("reasoning_content"),
2864 "raw reasoning_content field should not leak into prompt"
2865 );
2866 }
2867
2868 #[test]
2872 fn test_reasoning_content_agentic_tool_call_roundtrip_render() {
2873 use super::OAIPromptFormatter;
2874 let formatter = make_test_formatter();
2875
2876 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2877 "model": "test-model",
2878 "messages": [
2879 {"role": "user", "content": "What is sqrt(144) + sqrt(256)?"},
2880 {
2881 "role": "assistant",
2882 "content": null,
2883 "reasoning_content": "I need to compute both square roots. Let me start with sqrt(144).",
2884 "tool_calls": [{
2885 "id": "call_0",
2886 "type": "function",
2887 "function": {
2888 "name": "calculator",
2889 "arguments": "{\"expr\": \"sqrt(144)\"}"
2890 }
2891 }]
2892 },
2893 {
2894 "role": "tool",
2895 "tool_call_id": "call_0",
2896 "content": "12"
2897 },
2898 {
2899 "role": "assistant",
2900 "content": "sqrt(144) = 12 and sqrt(256) = 16, so the answer is 28.",
2901 "reasoning_content": "Got 12 for sqrt(144). Now sqrt(256) = 16. Sum is 28."
2902 },
2903 {"role": "user", "content": "Thanks!"}
2904 ]
2905 }))
2906 .unwrap();
2907
2908 let rendered = formatter.render(&request).unwrap();
2909
2910 assert!(
2912 rendered.contains("<think>I need to compute both square roots"),
2913 "first turn reasoning must be in prompt, got: {}",
2914 rendered
2915 );
2916 assert!(
2918 rendered.contains("<think>Got 12 for sqrt(144)"),
2919 "second turn reasoning must be in prompt"
2920 );
2921 assert!(
2922 rendered.contains("the answer is 28"),
2923 "final answer content must be preserved"
2924 );
2925 assert!(
2927 !rendered.contains("reasoning_content"),
2928 "raw reasoning_content field should not leak into prompt"
2929 );
2930 }
2931
2932 #[test]
2934 fn test_reasoning_injected_when_template_ignores_it() {
2935 use super::OAIPromptFormatter;
2936 let formatter = make_test_formatter();
2937
2938 assert!(!formatter.default_template_handles_reasoning);
2940 assert!(!formatter.tool_use_template_handles_reasoning);
2941
2942 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2943 "model": "test-model",
2944 "messages": [
2945 {"role": "user", "content": "Hello"},
2946 {
2947 "role": "assistant",
2948 "content": "Hi.",
2949 "reasoning_content": "The user said hello."
2950 },
2951 {"role": "user", "content": "Bye"}
2952 ]
2953 }))
2954 .unwrap();
2955
2956 let rendered = formatter.render(&request).unwrap();
2957 assert!(
2958 rendered.contains("<think>The user said hello.</think>"),
2959 "injection must happen when template ignores reasoning_content, got: {}",
2960 rendered
2961 );
2962 }
2963
2964 #[test]
2966 fn test_reasoning_not_injected_when_template_handles_it() {
2967 use super::tokcfg::ChatTemplate;
2968 use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
2969
2970 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>
2972{%- endif %}{{ message.role }}: {{ message.content }}
2973{%- endfor %}
2974{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2975
2976 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2977 "chat_template": template
2978 }))
2979 .unwrap();
2980
2981 let formatter =
2982 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2983
2984 assert!(formatter.default_template_handles_reasoning);
2986 assert!(formatter.tool_use_template_handles_reasoning);
2987
2988 let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2989 "model": "test-model",
2990 "messages": [
2991 {"role": "user", "content": "Hello"},
2992 {
2993 "role": "assistant",
2994 "content": "Hi.",
2995 "reasoning_content": "The user said hello."
2996 },
2997 {"role": "user", "content": "Bye"}
2998 ]
2999 }))
3000 .unwrap();
3001
3002 let rendered = formatter.render(&request).unwrap();
3003
3004 assert!(
3006 rendered.contains("<think>The user said hello.</think>"),
3007 "template must render reasoning_content natively, got: {}",
3008 rendered
3009 );
3010 let think_count = rendered.matches("<think>").count();
3012 assert_eq!(
3013 think_count, 1,
3014 "must have exactly one <think> block (from template), got {} in: {}",
3015 think_count, rendered
3016 );
3017 }
3018
3019 const QWEN3_THINKING_TEMPLATE: &str = r##"{%- if tools %}
3023 {{- '<|im_start|>system\n' }}
3024 {%- if messages[0].role == 'system' %}
3025 {{- messages[0].content + '\n\n' }}
3026 {%- endif %}
3027 {{- "# 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>" }}
3028 {%- for tool in tools %}
3029 {{- "\n" }}
3030 {{- tool | tojson }}
3031 {%- endfor %}
3032 {{- "\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" }}
3033{%- else %}
3034 {%- if messages[0].role == 'system' %}
3035 {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
3036 {%- endif %}
3037{%- endif %}
3038{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
3039{%- for message in messages[::-1] %}
3040 {%- set index = (messages|length - 1) - loop.index0 %}
3041 {%- 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>')) %}
3042 {%- set ns.multi_step_tool = false %}
3043 {%- set ns.last_query_index = index %}
3044 {%- endif %}
3045{%- endfor %}
3046{%- for message in messages %}
3047 {%- if message.content is string %}
3048 {%- set content = message.content %}
3049 {%- else %}
3050 {%- set content = '' %}
3051 {%- endif %}
3052 {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
3053 {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
3054 {%- elif message.role == "assistant" %}
3055 {%- set reasoning_content = '' %}
3056 {%- if message.reasoning_content is string %}
3057 {%- set reasoning_content = message.reasoning_content %}
3058 {%- else %}
3059 {%- if '</think>' in content %}
3060 {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
3061 {%- set content = content.split('</think>')[-1].lstrip('\n') %}
3062 {%- endif %}
3063 {%- endif %}
3064 {%- if loop.index0 > ns.last_query_index %}
3065 {%- if loop.last or (not loop.last and reasoning_content) %}
3066 {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
3067 {%- else %}
3068 {{- '<|im_start|>' + message.role + '\n' + content }}
3069 {%- endif %}
3070 {%- else %}
3071 {{- '<|im_start|>' + message.role + '\n' + content }}
3072 {%- endif %}
3073 {%- if message.tool_calls %}
3074 {%- for tool_call in message.tool_calls %}
3075 {%- if (loop.first and content) or (not loop.first) %}
3076 {{- '\n' }}
3077 {%- endif %}
3078 {%- if tool_call.function %}
3079 {%- set tool_call = tool_call.function %}
3080 {%- endif %}
3081 {{- '<tool_call>\n{"name": "' }}
3082 {{- tool_call.name }}
3083 {{- '", "arguments": ' }}
3084 {%- if tool_call.arguments is string %}
3085 {{- tool_call.arguments }}
3086 {%- else %}
3087 {{- tool_call.arguments | tojson }}
3088 {%- endif %}
3089 {{- '}\n</tool_call>' }}
3090 {%- endfor %}
3091 {%- endif %}
3092 {{- '<|im_end|>\n' }}
3093 {%- elif message.role == "tool" %}
3094 {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
3095 {{- '<|im_start|>user' }}
3096 {%- endif %}
3097 {{- '\n<tool_response>\n' }}
3098 {{- content }}
3099 {{- '\n</tool_response>' }}
3100 {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
3101 {{- '<|im_end|>\n' }}
3102 {%- endif %}
3103 {%- endif %}
3104{%- endfor %}
3105{%- if add_generation_prompt %}
3106 {{- '<|im_start|>assistant\n<think>\n' }}
3107{%- endif %}"##;
3108
3109 fn qwen3_thinking_formatter() -> HfTokenizerConfigJsonFormatter {
3110 let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
3111 "chat_template": QWEN3_THINKING_TEMPLATE,
3112 }))
3113 .unwrap();
3114 HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
3115 }
3116
3117 #[test]
3118 fn test_qwen3_thinking_template_flags_detected() {
3119 let formatter = qwen3_thinking_formatter();
3120 assert!(
3121 formatter.tool_use_template_handles_reasoning,
3122 "template references reasoning_content directly"
3123 );
3124 assert!(
3127 formatter.default_template_handles_tool_calls_arguments_string,
3128 "default template branches on `arguments is string`"
3129 );
3130 assert!(
3131 formatter.tool_use_template_handles_tool_calls_arguments_string,
3132 "tool_use template branches on `arguments is string`"
3133 );
3134 }
3135
3136 #[test]
3146 fn test_qwen3_thinking_append_only_across_tool_use_turn() {
3147 let formatter = qwen3_thinking_formatter();
3148
3149 let tools = serde_json::json!([{
3150 "type": "function",
3151 "function": {
3152 "name": "get_weather",
3153 "description": "Get the current weather for a location",
3154 "parameters": {
3155 "type": "object",
3156 "properties": {
3157 "location": {"type": "string"},
3158 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
3159 },
3160 "required": ["location"]
3161 }
3162 }
3163 }]);
3164
3165 let turn1_request: NvCreateChatCompletionRequest =
3167 serde_json::from_value(serde_json::json!({
3168 "model": "qwen3-thinking",
3169 "messages": [
3170 {"role": "system", "content": "You are a helpful assistant."},
3171 {"role": "user", "content": "What's the weather in San Francisco?"},
3172 ],
3173 "tools": tools,
3174 }))
3175 .unwrap();
3176 let p1 = formatter.render(&turn1_request).unwrap();
3177
3178 let model_emitted = "I'll call get_weather for SF.\n\
3182 </think>\n\n\
3183 <tool_call>\n\
3184 {\"name\": \"get_weather\", \"arguments\": {\"location\": \"San Francisco\", \"unit\": \"celsius\"}}\n\
3185 </tool_call><|im_end|>\n";
3186 let wire_after_t1 = format!("{p1}{model_emitted}");
3187
3188 let turn2_request: NvCreateChatCompletionRequest =
3191 serde_json::from_value(serde_json::json!({
3192 "model": "qwen3-thinking",
3193 "messages": [
3194 {"role": "system", "content": "You are a helpful assistant."},
3195 {"role": "user", "content": "What's the weather in San Francisco?"},
3196 {
3197 "role": "assistant",
3198 "content": "",
3199 "reasoning_content": "I'll call get_weather for SF.",
3200 "tool_calls": [{
3201 "id": "call_sf",
3202 "type": "function",
3203 "function": {
3204 "name": "get_weather",
3205 "arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
3206 }
3207 }]
3208 },
3209 {
3210 "role": "tool",
3211 "tool_call_id": "call_sf",
3212 "content": "{\"temp\": 18, \"conditions\": \"Foggy\"}"
3213 }
3214 ],
3215 "tools": tools,
3216 }))
3217 .unwrap();
3218 let p2 = formatter.render(&turn2_request).unwrap();
3219
3220 if !p2.starts_with(&wire_after_t1) {
3221 let div = wire_after_t1
3223 .as_bytes()
3224 .iter()
3225 .zip(p2.as_bytes())
3226 .position(|(a, b)| a != b)
3227 .unwrap_or_else(|| wire_after_t1.len().min(p2.len()));
3228 let lo = div.saturating_sub(40);
3229 panic!(
3230 "turn-2 prompt is NOT a prefix-extension of [turn-1 + model bytes]\n \
3231 diverges at byte {div}\n \
3232 wire ends: ...{}|{}\n \
3233 t2 has: ...{}|{}",
3234 String::from_utf8_lossy(&wire_after_t1.as_bytes()[lo..div]),
3235 String::from_utf8_lossy(
3236 &wire_after_t1.as_bytes()[div..(div + 60).min(wire_after_t1.len())]
3237 ),
3238 String::from_utf8_lossy(&p2.as_bytes()[lo..div]),
3239 String::from_utf8_lossy(&p2.as_bytes()[div..(div + 60).min(p2.len())]),
3240 );
3241 }
3242
3243 let suffix = &p2[wire_after_t1.len()..];
3246 assert!(
3247 suffix.contains("<tool_response>"),
3248 "appended bytes must include the tool response, got: {suffix}"
3249 );
3250 assert!(
3251 suffix.ends_with("<|im_start|>assistant\n<think>\n"),
3252 "appended bytes must end with the next generation prompt, got: {suffix}"
3253 );
3254 }
3255}