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