Skip to main content

dynamo_renderer/template/
oai.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use super::*;
5
6use crate::{OAIChatLikeRequest, TextInput};
7use minijinja::{context, value::Value};
8use serde_json::json;
9use std::result::Result::Ok;
10
11/// Fix a tool schema that is missing `type`/`properties`. `pub` so consumers
12/// can normalize their own `tools` value when implementing
13/// [`crate::OAIChatLikeRequest::tools`].
14pub fn may_be_fix_tool_schema(tools: serde_json::Value) -> Option<Value> {
15    // No need to validate or enforce other schema checks as the basic Named function schema is already validated while creating the request.
16    // Empty parameters is allowed by OpenAI at request level. Need to enforce it at template level.
17    // Whenever parameters is empty, insert "type": "object" and "properties": {}
18    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                // Backfill a missing/null `description`. It's optional in the
24                // OpenAI tool schema, but some chat templates (e.g. gpt-oss
25                // harmony) concatenate it unconditionally and fail on an
26                // `undefined`/null value.
27                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                // Only operate if parameters is an object
40                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 empty, we need to insert both
49                    if is_empty {
50                        needs_type = true;
51                        needs_properties = true;
52                    } else {
53                        // If not empty, check if type/properties are missing
54                        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
88/// Default media type conversions for multimodal content.
89/// Maps source types (e.g., "image_url") to target placeholder types (e.g., "image").
90const DEFAULT_MEDIA_TYPE_CONVERSIONS: &[(&str, &str)] = &[
91    ("image_url", "image"),
92    ("video_url", "video"),
93    ("audio_url", "audio"),
94];
95
96/// Convert media URL content parts to empty placeholder types.
97fn 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    // preserve_arrays=true: strings → arrays (multimodal)
121    // preserve_arrays=false: text-only arrays → strings (standard)
122    // image_placeholder_template: when `preserve_arrays=false` and the array
123    // mixes text + image parts, this template (e.g. `<|image_{n}|>`) lets us
124    // flatten by substituting image parts with model-family placeholders
125    // instead of leaving the raw array for the template, which would crash
126    // string-content templates like Phi-3-vision's `'+' message.content`.
127
128    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                // Case 1: String to Array (for multimodal templates)
137                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                // Case 2: Array processing
149                Some(serde_json::Value::Array(content_array)) => {
150                    // First, convert any media URL parts to placeholders (e.g., image_url → image)
151                    let content_array = convert_media_url_to_placeholder(
152                        content_array,
153                        DEFAULT_MEDIA_TYPE_CONVERSIONS,
154                    );
155
156                    // Check if it's text-only (after media URL conversion)
157                    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                            // Flatten text-only arrays to string for standard templates
169                            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                            // Mixed text+image array for a string-content
183                            // template — flatten with model-family image
184                            // placeholders inlined where the image parts were.
185                            // An empty `placeholder_tpl` ("") drops the image
186                            // parts entirely while keeping the text — used by
187                            // pure pass-through / encoder-decoder templates
188                            // (Nemotron-Parse) whose vision encoder consumes the
189                            // image out-of-band, so no text token represents it.
190                            // The `is_empty` guard preserves a literal `[]`
191                            // content (matches pre-PR behavior); flattening
192                            // an empty array to `""` would silently change
193                            // what the template renders.
194                            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                            // Keep as array (with media_url → media placeholder conversion applied)
201                            msg_object.insert(
202                                "content".to_string(),
203                                serde_json::Value::Array(content_array),
204                            );
205                        }
206                    }
207                    modified_msg
208                }
209                _ => msg.clone(), // No conversion needed
210            }
211        })
212        .collect();
213
214    Value::from_serialize(&updated_messages)
215}
216
217/// Concatenate a mixed-content array (text parts + image placeholders) into a
218/// single string. Text parts contribute their `text` field as-is; non-text
219/// parts (image, video, audio after the URL→placeholder conversion in
220/// `convert_media_url_to_placeholder`) emit the per-family placeholder with
221/// `{n}` substituted by the 1-based index of the image in the message.
222///
223/// Used in `may_be_fix_msg_content` when `preserve_arrays=false` and the
224/// template knows a placeholder convention — currently Phi-3-vision
225/// (`<|image_{n}|>`), LLaVA-1.5 (`<image>`), and pure pass-through /
226/// encoder-decoder templates (`""`, image emits nothing — Nemotron-Parse).
227/// With an empty `placeholder_tpl` the non-text parts contribute no characters,
228/// so the result is the concatenated text parts only.
229///
230/// **Caveat — non-text index slot:** `img_idx` increments for every non-text
231/// part, not just images. The current supported families (Phi-3, LLaVA-1.5)
232/// are image-only so there's no collision today, but a future image+video
233/// family would silently consume an image-index slot for each video/audio
234/// part and emit the image placeholder there. When adding a family that
235/// mixes modalities in one message, either:
236///   1. expand this function with per-modality placeholder strings, or
237///   2. assert in `convert_media_url_to_placeholder` that only "image"
238///      placeholders reach this path.
239fn 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    // Deserialize `tool_calls[].function.arguments` from JSON strings to
259    // objects/arrays before template rendering — avoids double encoding
260    // and enables iteration. Skipped for templates whose own `is string`
261    // branch wants the raw string verbatim (see render()).
262    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    // Legacy (deprecated) OpenAI `function_call.arguments` path. Kept separate
283    // from `tool_calls` normalization so the per-template `arguments is string`
284    // opt-out — which only refers to `tool_call.arguments` inside the
285    // tool_calls loop — does not accidentally suppress this path.
286    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
301/// Inject `reasoning_content` back into the `content` field as `<think>` blocks.
302///
303/// Chat templates only reference `{{ message.content }}` — they don't know about
304/// `reasoning_content`. Without this injection, the model's prior chain-of-thought
305/// is silently dropped across turns.
306///
307/// Uses `<think>`/`</think>` delimiters — the same tags that reasoning models emit
308/// and that the reasoning parser strips on output. Reasoning is prepended to content
309/// to match the original generation order (`<think>...</think> response`).
310///
311/// Segments are concatenated rather than interleaved with tool_calls because Jinja
312/// templates render `tool_calls` separately from `content`. The model still sees
313/// all reasoning text before the template-rendered tool call block.
314fn 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            // Content is a string or null — prepend reasoning as text
349            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            // Content is an array (multimodal) — prepend as a text part
356            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            // Other types (number, bool, object) — skip, don't corrupt
366            _ => continue,
367        }
368
369        // Remove so the template doesn't see both the injected <think> in content
370        // and the original reasoning_content field.
371        if let Some(obj) = msg.as_object_mut() {
372            obj.remove("reasoning_content");
373        }
374    }
375}
376
377/// Default [`OAIChatLikeRequest`] impl for the bare `dynamo-protocols` chat
378/// request. Lets any consumer (e.g. a standalone OpenAI frontend over an
379/// engine) render HF chat templates directly from the wire type, without
380/// defining their own wrapper. Consumers with extra fields (Dynamo's
381/// `NvCreateChatCompletionRequest`) provide their own impl.
382impl 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        // Using vLLM default behavior
424        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
436/// Joins two message contents without flattening to text: a part array is
437/// spliced part for part, so image parts survive a merge. A string target is
438/// promoted to an array when the source carries parts.
439fn 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        // Content is a string or a part array in every shape the schema allows.
473        (target, _) => target,
474    }
475}
476
477/// Merges `source` into `target`, leaving every other field on `target` intact.
478fn 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
496/// Rewrites an agent-client message stream into a shape strict chat templates
497/// accept. Each rewrite is gated on the restriction the load-time probe actually
498/// found, so a template is never reshaped for a rule it does not enforce.
499fn 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        // A template that rejects a system turn away from index 0 rejects a
508        // leading run of them too, so the run collapses into the first.
509        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        // Demoted in place, not folded to the front: a mid-conversation reminder
518        // that toggles would otherwise invalidate the whole KV prefix each turn.
519        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        // Strip tools when tool_choice is "none" and the flag is enabled, so the model
553        // doesn't see tool definitions and generate raw XML tool calls in its response.
554        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        // has_tools should be true if tools is a non-empty array
563        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        // Pick the concrete template before applying any template-specific
573        // message rewrites or field normalization.
574        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        // Pre-parse JSON-string `arguments` into objects — but only for templates
614        // that unconditionally `| tojson` them. Templates that branch on
615        // `tool_call.arguments is string` (Qwen3, Hermes) want the raw string
616        // verbatim so the rendered bytes match what the model emitted on the
617        // prior turn. Re-serializing through minijinja's compact `tojson` here
618        // breaks append-only prefix matching across multi-step tool use.
619        if !template_handles_tool_calls_args_string {
620            normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
621        }
622        // Legacy `function_call.arguments` is always normalized — the
623        // `arguments is string` opt-out only covers the modern `tool_calls`
624        // branch.
625        normalize_function_call_arguments_in_messages(&mut messages_for_template);
626
627        // Inject reasoning_content as <think> blocks into content — but only if
628        // the template doesn't handle it natively. Templates like Nemotron and
629        // Qwen3 reference reasoning_content directly in their Jinja logic; injecting
630        // would produce duplicate <think> blocks.
631        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        // Merge any additional args into the context last so they take precedence
646        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    // The crate's renderer tests exercise the bare-protocol request type via the
663    // default `OAIChatLikeRequest` impl above; Dynamo's `Nv*` wrapper lives in lib/llm.
664    use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
665    use minijinja::{Environment, context};
666
667    // --- adaptive system-message normalization (#11762) --------------------
668
669    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        // Dummy tokens: real templates that append eos/bos won't render without them.
676        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    // Rejects a non-leading system (Qwen3.5 shape); accepts consecutive users.
824    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    // Rejects consecutive user turns only; accepts a non-leading system.
833    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    // Rejects both restrictions (Gemma-3 / Mistral shape).
844    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    // Mirrors templates that only enforce role constraints when a particular
858    // tools shape is present.
859    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    // Ignores string content and only renders text parts from content arrays.
878    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    // Claude Code first-turn shape: top-level system + a mid-array system.
892    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        // The template accepts consecutive users, so demotion must not merge them.
921        assert!(!f.default_system_normalization.coalesce_consecutive_users);
922
923        // This shape returned a 500 before the probe existed.
924        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        // The template accepts a non-leading system, so it stays a system turn.
935        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    /// The mirror of the case above: the parts belong to the turn being merged
1044    /// away, not the one being merged into.
1045    #[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    /// Each restriction drives only its own rewrite, so a template is never
1087    /// reshaped for a rule it does not enforce.
1088    #[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    /// Renders every agent-client shape through every template in a corpus of
1144    /// real models. A failure means the probe missed a strict template, or the
1145    /// normalization it triggered was not enough to satisfy one.
1146    ///
1147    /// TEMPLATE_CORPUS points at a dir of `<name>.jinja` files + `manifest.json`
1148    /// mapping each to `{model}` (see experiments/system-probe):
1149    ///   TEMPLATE_CORPUS=... cargo test -p dynamo-renderer \
1150    ///     adaptive_system_corpus_audit -- --ignored --nocapture
1151    #[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        // Tool-call shapes are omitted: they need a per-family `tools` payload,
1161        // and the hermetic tests already cover them.
1162        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            // Some real templates use custom tags minijinja can't compile,
1200            // which says nothing about system normalization.
1201            let f = match try_formatter_for(&tmpl) {
1202                Some(f) => f,
1203                None => {
1204                    eprintln!("[skip-compile] {model}");
1205                    continue;
1206                }
1207            };
1208            // Vision templates need image inputs, so they can't render text-only
1209            // here. The probe's baseline guard leaves them unflagged anyway.
1210            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    /// End-to-end guard for the minijinja stack-overflow fix, exercised through
1254    /// Dynamo's real chat-template render path. A template that accumulates
1255    /// messages via `ns.items = ns.items + [m]` and then takes `|length`
1256    /// previously overflowed the native stack for long conversations (~1500+
1257    /// turns on a worker thread), core-dumping the frontend. Runs on a 2 MiB
1258    /// stack — the size of a Dynamo tokio worker thread — so a regression aborts
1259    /// deterministically instead of depending on the platform default.
1260    #[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                // The crash path: `|length` -> minijinja `Value::len()`.
1291                let rendered = formatter.render(&request).unwrap();
1292                assert_eq!(rendered.trim(), format!("COUNT={n}"));
1293            })
1294            .unwrap();
1295        handle.join().unwrap();
1296    }
1297
1298    /// Dev utility (ignored by default): dump the prompt Dynamo's renderer
1299    /// produces for a tool-calling chat request, so it can be diffed against
1300    /// vLLM's `openai_harmony` rendering — to see whether the gpt-oss Jinja
1301    /// `chat_template` actually emits the harmony "tool calls go to the
1302    /// commentary channel" guidance + `functions` namespace.
1303    ///
1304    /// Point GPTOSS_CHAT_TEMPLATE at the model's tokenizer_config.json (its
1305    /// `chat_template` field is extracted) OR a raw chat_template.jinja file:
1306    ///   GPTOSS_CHAT_TEMPLATE=/path/openai-gpt-oss-120b/tokenizer_config.json \
1307    ///     cargo test -p dynamo-renderer dump_gptoss_tool_prompt -- --ignored --nocapture
1308    #[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            // Prefer tokenizer_config.json from model dir if provided
1320            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        // Resolve the actual Jinja template. gpt-oss ships its chat template in a
1326        // separate `chat_template.jinja` file, NOT inside tokenizer_config.json,
1327        // so:
1328        //   * if the file is JSON with a `chat_template` field, use it;
1329        //   * otherwise, if a sibling `chat_template.jinja` exists, read that;
1330        //   * otherwise treat the file itself as the template.
1331        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        // Guard against silently echoing a non-template (e.g. a tokenizer_config.json
1354        // with no chat_template and no sibling .jinja).
1355        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        // Declare tools — the tool-channel guidance only renders when tools are present.
1370        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    /// Tests that media URL content parts are converted to empty placeholders.
1405    #[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        // Text parts should be unchanged
1418        assert_eq!(result[0]["type"], "text");
1419        assert_eq!(result[0]["text"], "Check this image:");
1420        // image_url should be converted to image placeholder
1421        assert_eq!(result[1]["type"], "image");
1422        assert!(result[1].get("image_url").is_none());
1423        // Text parts should be unchanged
1424        assert_eq!(result[2]["type"], "text");
1425        assert_eq!(result[2]["text"], "What do you see?");
1426    }
1427
1428    /// Tests that multiple media URL parts of the same type are all converted.
1429    #[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    /// Tests that only specified media types are converted, others preserved.
1447    #[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        // Only convert image_url
1456        let conversions = &[("image_url", "image")];
1457        let result = convert_media_url_to_placeholder(&content_array, conversions);
1458
1459        assert_eq!(result.len(), 3);
1460        // audio_url and video_url should be preserved as-is
1461        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        // Only image_url should be converted
1466        assert_eq!(result[2]["type"], "image");
1467        assert!(result[2].get("image_url").is_none());
1468    }
1469
1470    /// Tests converting multiple different media types at once.
1471    #[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        // Convert all media types
1482        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    /// Tests that empty conversions list preserves all content.
1501    #[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        // Everything should be preserved as-is
1513        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    /// Tests that DEFAULT_MEDIA_TYPE_CONVERSIONS only converts image_url,
1519    /// and preserves other media types like video_url and audio_url.
1520    #[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        // Use the actual DEFAULT_MEDIA_TYPE_CONVERSIONS
1530        let result =
1531            convert_media_url_to_placeholder(&content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
1532
1533        assert_eq!(result.len(), 4);
1534
1535        // image_url SHOULD be converted to image (it's in the default map)
1536        assert_eq!(result[0]["type"], "image");
1537        assert!(result[0].get("image_url").is_none());
1538
1539        // video_url should NOT be converted (not in the default map)
1540        assert_eq!(result[1]["type"], "video");
1541        assert!(result[1].get("video_url").is_none());
1542
1543        // audio_url should NOT be converted (not in the default map)
1544        assert_eq!(result[2]["type"], "audio");
1545        assert!(result[2].get("audio_url").is_none());
1546
1547        // text should be unchanged
1548        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        // `description` is optional in the OpenAI tool schema, but some chat
1659        // templates (e.g. gpt-oss harmony) concatenate it unconditionally and
1660        // fail on an `undefined`/null value. It must be backfilled to "".
1661        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        // An explicit null `description` must also be normalized to "".
1693        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        // A present `description` must be left untouched.
1721        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    /// Tests that content arrays (containing only text parts) are correctly concatenated.
1747    #[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        // Test array → string normalization (preserve_arrays=false for standard templates)
1766        let messages =
1767            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1768
1769        // Verify: text-only array is concatenated into a single string
1770        assert_eq!(
1771            messages[0]["content"],
1772            serde_json::Value::String("part 1\npart 2".to_string())
1773        );
1774    }
1775
1776    /// Tests that the function correctly handles a conversation
1777    /// with multiple roles and mixed message types:
1778    #[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        // Test array → string normalization (preserve_arrays=false for standard templates)
1813        let messages =
1814            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1815
1816        // Verify: System message with string content remains unchanged
1817        assert_eq!(
1818            messages[0]["content"],
1819            serde_json::Value::String("You are a helpful assistant".to_string())
1820        );
1821
1822        // Verify: User message with text-only array is concatenated
1823        assert_eq!(
1824            messages[1]["content"],
1825            serde_json::Value::String("Hello\nWorld".to_string())
1826        );
1827
1828        // Verify: Assistant message with string content remains unchanged
1829        assert_eq!(
1830            messages[2]["content"],
1831            serde_json::Value::String("Hi there!".to_string())
1832        );
1833
1834        // Verify: Second user message with text-only array is concatenated
1835        assert_eq!(
1836            messages[3]["content"],
1837            serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
1838        );
1839    }
1840
1841    /// Tests that empty content arrays remain unchanged.
1842    #[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        // Empty arrays should be preserved regardless of preserve_arrays setting
1858        let messages =
1859            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1860
1861        // Verify: Empty arrays are preserved as-is
1862        assert!(messages[0]["content"].is_array());
1863        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1864    }
1865
1866    /// Empty arrays must stay as `[]` even when a flatten-time placeholder
1867    /// template is provided (Phi-3 / LLaVA-1.5 path). Without the
1868    /// `!content_array.is_empty()` guard in `may_be_fix_msg_content`,
1869    /// an empty content array would silently flatten to `""` and the
1870    /// chat template would render an entirely empty message instead of
1871    /// failing or being preserved.
1872    #[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        // preserve_arrays=false + image_placeholder_template=Some(...) is
1888        // the combination that previously flattened `[]` to `""`.
1889        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    /// Tests that messages with simple string content remain unchanged.
1904    #[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        // Test with preserve_arrays=false (standard templates)
1920        let messages =
1921            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1922
1923        // Verify: String content is not modified
1924        assert_eq!(
1925            messages[0]["content"],
1926            serde_json::Value::String("Simple text message".to_string())
1927        );
1928    }
1929
1930    /// Tests that content arrays with mixed types (text + non-text) remain as arrays,
1931    /// and that image_url is converted to image placeholder.
1932    #[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        // Mixed content should be preserved regardless of preserve_arrays setting
1952        let messages =
1953            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1954
1955        // Verify: Mixed content types are preserved as array for template handling
1956        // image_url should be converted to image placeholder
1957        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    /// Mixed text+image array with a string-content template and an
1967    /// `<|image_{n}|>`-style placeholder (Phi-3-vision) — content must be
1968    /// flattened to a single string with numbered image markers in place of
1969    /// the image parts. The previous default (leave as array) would crash
1970    /// the Phi-3 template's `'+' message.content` concatenation.
1971    #[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    /// Same flattening with a static placeholder (LLaVA-1.5 `<image>`).
2003    #[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    /// Nemotron-Parse pass-through path: a mixed text+image array with an
2029    /// empty placeholder (`""`) flattens to the text parts only — the image
2030    /// contributes nothing because the vision encoder consumes it out-of-band.
2031    /// Without this, `{{ message.content }}` would JSON-serialize the array
2032    /// into the prompt (the gibberish failure mode).
2033    #[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    /// End-to-end render through the Nemotron-Parse pass-through chat template:
2061    /// a text+image chat request must produce exactly the control-token prompt,
2062    /// with the image dropped from the rendered text. The renderer is agnostic
2063    /// to the control tokens themselves (they are just the text part, passed
2064    /// through verbatim), so both `predict_no_text_in_pic` and
2065    /// `predict_text_in_pic` prompts round-trip identically.
2066    #[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    /// Tests that content arrays containing only non-text types remain as arrays,
2104    /// and image_url types are converted to image placeholders.
2105    #[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        // Non-text arrays should be preserved regardless of preserve_arrays setting
2124        let messages =
2125            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2126
2127        // Verify: Non-text content arrays are preserved, with image_url converted to image
2128        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        // Due to minijinja limitations the expressions in conditional statements may not be short-circuited
2141        // This checks that our custom length filter works to avoid errors in this scenario
2142        // length should return 0 if tools is None and 'if tools is iterable and tools | length > 0' should evaluate to false
2143        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        // Because we return None for tools when there are no tools this scenario should also be evaluate to false
2152        // This is similar to the default jinja template behavior seen with llama models which check if tools is not none to activate tool mode
2153        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    /// Tests mixed content type scenarios.
2201    #[test]
2202    fn test_may_be_fix_msg_content_multiple_content_types() {
2203        // Scenario 1: Multiple different content types (text + image + audio)
2204        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        // Mixed types should preserve array structure, with image_url converted to image
2226        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        // Scenario 2: Unknown/future content types mixed with text
2236        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        // Unknown types mixed with text should preserve array
2256        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        // Message with tool_calls containing JSON string arguments
2265        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        // Should produce clean JSON without double-encoding, with Python
2288        // json.dumps separators (what transformers' tojson emits).
2289        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        // Test deprecated function_call format (OpenAI compat)
2326        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        // Malformed JSON should be left as a string
2345        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        // Apply content normalization with preserve_arrays=false (standard templates)
2395        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        // Multimodal content preserved as array (mixed types not flattened)
2401        assert!(messages[0]["content"].is_array());
2402        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
2403
2404        // Tool arguments deserialized to object
2405        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    /// Tests string → array normalization for multimodal templates
2413    #[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        // Test with preserve_arrays=true (multimodal templates)
2429        let messages =
2430            serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
2431
2432        // Verify: String is converted to array format
2433        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    /// Tests that arrays are preserved when preserve_arrays=true
2441    #[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        // Test with preserve_arrays=true (multimodal templates)
2460        let messages =
2461            serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
2462
2463        // Verify: Array is preserved as-is
2464        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    /// Helper to build a formatter with a simple tool-aware template.
2505    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    /// Helper to build a request with tools and optional tool_choice.
2580    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        // Assistant message with reasoning_content segments and tool_calls
2636        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        // reasoning_content should be removed
2671        assert!(
2672            assistant.get("reasoning_content").is_none(),
2673            "reasoning_content should be removed after injection"
2674        );
2675
2676        // content should have <think> blocks prepended (empty segment skipped)
2677        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        // Empty third segment should NOT produce <think></think>
2688        assert!(
2689            !content.contains("<think></think>"),
2690            "empty segments should be skipped"
2691        );
2692        // Original content should be preserved at the end
2693        assert!(
2694            content.ends_with("Let me calculate those."),
2695            "original content should be at the end, got: {}",
2696            content
2697        );
2698
2699        // tool_calls should be untouched
2700        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    /// Regression: when a config ships a separate non-tool `default` template
2791    /// (dict form), adapting only the `tool_use` template to read
2792    /// `reasoning_content` must NOT suppress `<think>` injection on the
2793    /// `default` path. A global `any()` flag would flip true off the adapted
2794    /// `tool_use` template and silently drop reasoning on no-tool renders.
2795    #[test]
2796    fn test_reasoning_flag_is_per_template_not_global() {
2797        // Plain default template: renders content, never mentions reasoning_content
2798        // and lacks the Gemma4 fingerprint, so it is left untouched.
2799        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        // The adapted Gemma4 tool_use template handles reasoning natively; the
2813        // untouched plain default template does not. The flag must reflect that
2814        // per-template split, not a global OR across both.
2815        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        // A no-tools request routes to `default`. Reasoning must still be injected
2825        // as a <think> block — not silently dropped by the tool_use template's flag.
2826        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        // reasoning_content present but content is null
2870        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        // User message should be untouched
2899        assert!(messages[0].get("reasoning_content").is_some());
2900    }
2901
2902    // Helper: create a formatter with a minimal chat template for render tests
2903    fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
2904        use super::tokcfg::ChatTemplate;
2905        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2906
2907        // Minimal template that renders content verbatim — enough to verify
2908        // that reasoning_content injection works through the full pipeline.
2909        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    // Verify reasoning_content (Text variant) from a prior assistant turn
2922    // appears as a <think> block in the rendered prompt.
2923    #[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    // Verify a full agentic flow: assistant reasons, calls a tool, gets a
2960    // result, then reasons again before answering. Both reasoning turns must
2961    // survive into the rendered prompt.
2962    #[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        // First assistant turn: reasoning with tool call, null content
3002        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        // Second assistant turn: reasoning with final answer
3008        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        // No raw reasoning_content in output
3017        assert!(
3018            !rendered.contains("reasoning_content"),
3019            "raw reasoning_content field should not leak into prompt"
3020        );
3021    }
3022
3023    // Template that does NOT reference reasoning_content — injection should happen.
3024    #[test]
3025    fn test_reasoning_injected_when_template_ignores_it() {
3026        use super::OAIPromptFormatter;
3027        let formatter = make_test_formatter();
3028
3029        // Formatter uses a simple template that doesn't reference reasoning_content
3030        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    // Template that DOES reference reasoning_content — injection must be skipped.
3056    #[test]
3057    fn test_reasoning_not_injected_when_template_handles_it() {
3058        use super::tokcfg::ChatTemplate;
3059        use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
3060
3061        // Template that natively renders reasoning_content (like Nemotron/Qwen3)
3062        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        // Verify detection worked
3076        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        // Template renders reasoning natively — no duplicate injection
3096        assert!(
3097            rendered.contains("<think>The user said hello.</think>"),
3098            "template must render reasoning_content natively, got: {}",
3099            rendered
3100        );
3101        // Must NOT have double <think> blocks
3102        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    /// Real Qwen3-4B-Thinking-2507 chat template (verbatim from
3111    /// `Qwen/Qwen3-4B-Thinking-2507/tokenizer_config.json`). Used to
3112    /// regression-test append-only rendering across multi-step tool use.
3113    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        // The Qwen3-Thinking template is registered as both `default` and
3216        // `tool_use` (single-string HF chat template), so both flags must fire.
3217        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    /// Across a multi-step tool-use turn, the rendered prompt for turn N+1
3228    /// must be a strict prefix-extension of [turn-N prompt + bytes the model
3229    /// emitted on turn N]. Otherwise KV-cache prefix matching falls off a
3230    /// cliff every time a tool result comes back.
3231    ///
3232    /// The Qwen3-Thinking template's `is string` branch (template lines 63-67)
3233    /// renders `tool_call.arguments` verbatim from the OpenAI-canonical JSON
3234    /// string. Pre-parsing that string into an object forces the `else` branch
3235    /// and re-emits with minijinja's compact `tojson`, breaking append-only.
3236    #[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        // Turn 1: server is asked to produce the first assistant turn.
3257        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        // Bytes the model emits next. Spacing matches the Qwen3 training
3270        // distribution (Python jinja2 / json.dumps defaults: `, ` and `: `).
3271        // Empty content + reasoning + a tool call.
3272        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        // Turn 2: client sends the prior assistant turn back in OpenAI canonical
3280        // form (arguments as a JSON STRING with spaces) plus the tool result.
3281        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            // Find first divergence and report it for easy debugging.
3313            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        // The only new bytes in P2 should be the tool response and the next
3335        // generation prompt — nothing in the prior conversation should change.
3336        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}