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 std::result::Result::Ok;
9
10/// Fix a tool schema that is missing `type`/`properties`. `pub` so consumers
11/// can normalize their own `tools` value when implementing
12/// [`crate::OAIChatLikeRequest::tools`].
13pub fn may_be_fix_tool_schema(tools: serde_json::Value) -> Option<Value> {
14    // No need to validate or enforce other schema checks as the basic Named function schema is already validated while creating the request.
15    // Empty parameters is allowed by OpenAI at request level. Need to enforce it at template level.
16    // Whenever parameters is empty, insert "type": "object" and "properties": {}
17    let mut updated_tools = Vec::new();
18    if let Some(arr) = tools.as_array() {
19        for tool in arr {
20            let mut tool = tool.clone();
21            if let Some(function) = tool.get_mut("function") {
22                // Backfill a missing/null `description`. It's optional in the
23                // OpenAI tool schema, but some chat templates (e.g. gpt-oss
24                // harmony) concatenate it unconditionally and fail on an
25                // `undefined`/null value.
26                if let Some(obj) = function.as_object_mut()
27                    && !matches!(obj.get("description"), Some(serde_json::Value::String(_)))
28                {
29                    obj.insert(
30                        "description".to_string(),
31                        serde_json::Value::String(String::new()),
32                    );
33                }
34            }
35            if let Some(function) = tool.get_mut("function")
36                && let Some(parameters) = function.get_mut("parameters")
37            {
38                // Only operate if parameters is an object
39                if parameters.is_object() {
40                    let mut needs_type = false;
41                    let mut needs_properties = false;
42                    let is_empty = parameters
43                        .as_object()
44                        .map(|o| o.is_empty())
45                        .unwrap_or(false);
46
47                    // If empty, we need to insert both
48                    if is_empty {
49                        needs_type = true;
50                        needs_properties = true;
51                    } else {
52                        // If not empty, check if type/properties are missing
53                        if let Some(obj) = parameters.as_object() {
54                            if !obj.contains_key("type") {
55                                needs_type = true;
56                            }
57                            if !obj.contains_key("properties") {
58                                needs_properties = true;
59                            }
60                        }
61                    }
62
63                    if (needs_type || needs_properties)
64                        && let Some(obj) = parameters.as_object_mut()
65                    {
66                        if needs_type {
67                            obj.insert(
68                                "type".to_string(),
69                                serde_json::Value::String("object".to_string()),
70                            );
71                        }
72                        if needs_properties {
73                            obj.insert(
74                                "properties".to_string(),
75                                serde_json::Value::Object(Default::default()),
76                            );
77                        }
78                    }
79                }
80            }
81            updated_tools.push(tool);
82        }
83    }
84    Some(Value::from_serialize(&updated_tools))
85}
86
87/// Default media type conversions for multimodal content.
88/// Maps source types (e.g., "image_url") to target placeholder types (e.g., "image").
89const DEFAULT_MEDIA_TYPE_CONVERSIONS: &[(&str, &str)] = &[
90    ("image_url", "image"),
91    ("video_url", "video"),
92    ("audio_url", "audio"),
93];
94
95/// Convert media URL content parts to empty placeholder types.
96fn convert_media_url_to_placeholder(
97    content_array: &[serde_json::Value],
98    conversions: &[(&str, &str)],
99) -> Vec<serde_json::Value> {
100    content_array
101        .iter()
102        .map(|part| {
103            let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
104
105            if let Some((_, target_type)) = conversions.iter().find(|(src, _)| *src == part_type) {
106                serde_json::json!({"type": target_type})
107            } else {
108                part.clone()
109            }
110        })
111        .collect()
112}
113
114fn may_be_fix_msg_content(
115    messages: serde_json::Value,
116    preserve_arrays: bool,
117    image_placeholder_template: Option<&str>,
118) -> Value {
119    // preserve_arrays=true: strings → arrays (multimodal)
120    // preserve_arrays=false: text-only arrays → strings (standard)
121    // image_placeholder_template: when `preserve_arrays=false` and the array
122    // mixes text + image parts, this template (e.g. `<|image_{n}|>`) lets us
123    // flatten by substituting image parts with model-family placeholders
124    // instead of leaving the raw array for the template, which would crash
125    // string-content templates like Phi-3-vision's `'+' message.content`.
126
127    let Some(arr) = messages.as_array() else {
128        return Value::from_serialize(&messages);
129    };
130
131    let updated_messages: Vec<_> = arr
132        .iter()
133        .map(|msg| {
134            match msg.get("content") {
135                // Case 1: String to Array (for multimodal templates)
136                Some(serde_json::Value::String(text)) if preserve_arrays => {
137                    let mut modified_msg = msg.clone();
138                    if let Some(msg_object) = modified_msg.as_object_mut() {
139                        let content_array = serde_json::json!([{
140                            "type": "text",
141                            "text": text
142                        }]);
143                        msg_object.insert("content".to_string(), content_array);
144                    }
145                    modified_msg
146                }
147                // Case 2: Array processing
148                Some(serde_json::Value::Array(content_array)) => {
149                    // First, convert any media URL parts to placeholders (e.g., image_url → image)
150                    let content_array = convert_media_url_to_placeholder(
151                        content_array,
152                        DEFAULT_MEDIA_TYPE_CONVERSIONS,
153                    );
154
155                    // Check if it's text-only (after media URL conversion)
156                    let is_text_only_array = !content_array.is_empty()
157                        && content_array.iter().all(|part| {
158                            part.get("type")
159                                .and_then(|type_field| type_field.as_str())
160                                .map(|type_str| type_str == "text")
161                                .unwrap_or(false)
162                        });
163
164                    let mut modified_msg = msg.clone();
165                    if let Some(msg_object) = modified_msg.as_object_mut() {
166                        if is_text_only_array && !preserve_arrays {
167                            // Flatten text-only arrays to string for standard templates
168                            let text_parts: Vec<&str> = content_array
169                                .iter()
170                                .filter_map(|part| part.get("text")?.as_str())
171                                .collect();
172                            let concatenated_text = text_parts.join("\n");
173                            msg_object.insert(
174                                "content".to_string(),
175                                serde_json::Value::String(concatenated_text),
176                            );
177                        } else if !preserve_arrays
178                            && !content_array.is_empty()
179                            && let Some(placeholder_tpl) = image_placeholder_template
180                        {
181                            // Mixed text+image array for a string-content
182                            // template — flatten with model-family image
183                            // placeholders inlined where the image parts were.
184                            // An empty `placeholder_tpl` ("") drops the image
185                            // parts entirely while keeping the text — used by
186                            // pure pass-through / encoder-decoder templates
187                            // (Nemotron-Parse) whose vision encoder consumes the
188                            // image out-of-band, so no text token represents it.
189                            // The `is_empty` guard preserves a literal `[]`
190                            // content (matches pre-PR behavior); flattening
191                            // an empty array to `""` would silently change
192                            // what the template renders.
193                            let flattened = flatten_mixed_content(&content_array, placeholder_tpl);
194                            msg_object.insert(
195                                "content".to_string(),
196                                serde_json::Value::String(flattened),
197                            );
198                        } else {
199                            // Keep as array (with media_url → media placeholder conversion applied)
200                            msg_object.insert(
201                                "content".to_string(),
202                                serde_json::Value::Array(content_array),
203                            );
204                        }
205                    }
206                    modified_msg
207                }
208                _ => msg.clone(), // No conversion needed
209            }
210        })
211        .collect();
212
213    Value::from_serialize(&updated_messages)
214}
215
216/// Concatenate a mixed-content array (text parts + image placeholders) into a
217/// single string. Text parts contribute their `text` field as-is; non-text
218/// parts (image, video, audio after the URL→placeholder conversion in
219/// `convert_media_url_to_placeholder`) emit the per-family placeholder with
220/// `{n}` substituted by the 1-based index of the image in the message.
221///
222/// Used in `may_be_fix_msg_content` when `preserve_arrays=false` and the
223/// template knows a placeholder convention — currently Phi-3-vision
224/// (`<|image_{n}|>`), LLaVA-1.5 (`<image>`), and pure pass-through /
225/// encoder-decoder templates (`""`, image emits nothing — Nemotron-Parse).
226/// With an empty `placeholder_tpl` the non-text parts contribute no characters,
227/// so the result is the concatenated text parts only.
228///
229/// **Caveat — non-text index slot:** `img_idx` increments for every non-text
230/// part, not just images. The current supported families (Phi-3, LLaVA-1.5)
231/// are image-only so there's no collision today, but a future image+video
232/// family would silently consume an image-index slot for each video/audio
233/// part and emit the image placeholder there. When adding a family that
234/// mixes modalities in one message, either:
235///   1. expand this function with per-modality placeholder strings, or
236///   2. assert in `convert_media_url_to_placeholder` that only "image"
237///      placeholders reach this path.
238fn flatten_mixed_content(parts: &[serde_json::Value], placeholder_tpl: &str) -> String {
239    let mut out = String::new();
240    let mut img_idx: u32 = 1;
241    for part in parts {
242        let type_str = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
243        if type_str == "text" {
244            if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
245                out.push_str(text);
246            }
247        } else if !type_str.is_empty() {
248            let placeholder = placeholder_tpl.replace("{n}", &img_idx.to_string());
249            out.push_str(&placeholder);
250            img_idx += 1;
251        }
252    }
253    out
254}
255
256fn normalize_tool_calls_arguments_in_messages(messages: &mut serde_json::Value) {
257    // Deserialize `tool_calls[].function.arguments` from JSON strings to
258    // objects/arrays before template rendering — avoids double encoding
259    // and enables iteration. Skipped for templates whose own `is string`
260    // branch wants the raw string verbatim (see render()).
261    let Some(msgs) = messages.as_array_mut() else {
262        return;
263    };
264
265    for msg in msgs.iter_mut() {
266        if let Some(tool_calls) = msg.get_mut("tool_calls").and_then(|v| v.as_array_mut()) {
267            for tc in tool_calls {
268                if let Some(function) = tc.get_mut("function").and_then(|v| v.as_object_mut())
269                    && let Some(args) = function.get_mut("arguments")
270                    && let Some(s) = args.as_str()
271                    && let Ok(parsed) = serde_json::from_str(s)
272                {
273                    *args = parsed;
274                }
275            }
276        }
277    }
278}
279
280fn normalize_function_call_arguments_in_messages(messages: &mut serde_json::Value) {
281    // Legacy (deprecated) OpenAI `function_call.arguments` path. Kept separate
282    // from `tool_calls` normalization so the per-template `arguments is string`
283    // opt-out — which only refers to `tool_call.arguments` inside the
284    // tool_calls loop — does not accidentally suppress this path.
285    let Some(msgs) = messages.as_array_mut() else {
286        return;
287    };
288
289    for msg in msgs.iter_mut() {
290        if let Some(function_call) = msg.get_mut("function_call").and_then(|v| v.as_object_mut())
291            && let Some(args) = function_call.get_mut("arguments")
292            && let Some(s) = args.as_str()
293            && let Ok(parsed) = serde_json::from_str(s)
294        {
295            *args = parsed;
296        }
297    }
298}
299
300/// Inject `reasoning_content` back into the `content` field as `<think>` blocks.
301///
302/// Chat templates only reference `{{ message.content }}` — they don't know about
303/// `reasoning_content`. Without this injection, the model's prior chain-of-thought
304/// is silently dropped across turns.
305///
306/// Uses `<think>`/`</think>` delimiters — the same tags that reasoning models emit
307/// and that the reasoning parser strips on output. Reasoning is prepended to content
308/// to match the original generation order (`<think>...</think> response`).
309///
310/// Segments are concatenated rather than interleaved with tool_calls because Jinja
311/// templates render `tool_calls` separately from `content`. The model still sees
312/// all reasoning text before the template-rendered tool call block.
313fn inject_reasoning_content_into_messages(messages: &mut serde_json::Value) {
314    let Some(msgs) = messages.as_array_mut() else {
315        return;
316    };
317
318    for msg in msgs.iter_mut() {
319        if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") {
320            continue;
321        }
322
323        let reasoning = match msg.get("reasoning_content") {
324            Some(serde_json::Value::String(s)) if !s.is_empty() => {
325                format!("<think>{}</think>", s)
326            }
327            Some(serde_json::Value::Array(segments)) => {
328                let mut result = String::new();
329                for seg in segments {
330                    if let Some(s) = seg.as_str()
331                        && !s.is_empty()
332                    {
333                        result.push_str("<think>");
334                        result.push_str(s);
335                        result.push_str("</think>");
336                    }
337                }
338                if result.is_empty() {
339                    continue;
340                }
341                result
342            }
343            _ => continue,
344        };
345
346        match msg.get("content") {
347            // Content is a string or null — prepend reasoning as text
348            Some(serde_json::Value::String(s)) if !s.is_empty() => {
349                msg["content"] = serde_json::Value::String(format!("{}{}", reasoning, s));
350            }
351            None | Some(serde_json::Value::Null) | Some(serde_json::Value::String(_)) => {
352                msg["content"] = serde_json::Value::String(reasoning);
353            }
354            // Content is an array (multimodal) — prepend as a text part
355            Some(serde_json::Value::Array(_)) => {
356                let think_part = serde_json::json!({
357                    "type": "text",
358                    "text": reasoning
359                });
360                if let Some(arr) = msg.get_mut("content").and_then(|v| v.as_array_mut()) {
361                    arr.insert(0, think_part);
362                }
363            }
364            // Other types (number, bool, object) — skip, don't corrupt
365            _ => continue,
366        }
367
368        // Remove so the template doesn't see both the injected <think> in content
369        // and the original reasoning_content field.
370        if let Some(obj) = msg.as_object_mut() {
371            obj.remove("reasoning_content");
372        }
373    }
374}
375
376/// Default [`OAIChatLikeRequest`] impl for the bare `dynamo-protocols` chat
377/// request. Lets any consumer (e.g. a standalone OpenAI frontend over an
378/// engine) render HF chat templates directly from the wire type, without
379/// defining their own wrapper. Consumers with extra fields (Dynamo's
380/// `NvCreateChatCompletionRequest`) provide their own impl.
381impl OAIChatLikeRequest for dynamo_protocols::types::CreateChatCompletionRequest {
382    fn model(&self) -> String {
383        self.model.clone()
384    }
385
386    fn messages(&self) -> Value {
387        let messages_json = serde_json::to_value(&self.messages).unwrap();
388        Value::from_serialize(&messages_json)
389    }
390
391    fn typed_messages(&self) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
392        Some(self.messages.as_slice())
393    }
394
395    fn tools(&self) -> Option<Value> {
396        if self.tools.is_none() {
397            None
398        } else {
399            Some(may_be_fix_tool_schema(
400                serde_json::to_value(&self.tools).unwrap(),
401            )?)
402        }
403    }
404
405    fn tool_choice(&self) -> Option<Value> {
406        if self.tool_choice.is_none() {
407            None
408        } else {
409            Some(Value::from_serialize(&self.tool_choice))
410        }
411    }
412
413    fn response_format(&self) -> Option<Value> {
414        self.response_format.as_ref().map(Value::from_serialize)
415    }
416
417    fn should_add_generation_prompt(&self) -> bool {
418        // Using vLLM default behavior
419        true
420    }
421
422    fn extract_text(&self) -> Option<TextInput> {
423        Some(TextInput::Single(String::new()))
424    }
425
426    fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
427        self.mm_processor_kwargs.as_ref()
428    }
429}
430
431impl OAIPromptFormatter for HfTokenizerConfigJsonFormatter {
432    fn supports_add_generation_prompt(&self) -> bool {
433        self.supports_add_generation_prompt
434    }
435
436    fn image_placeholder_template(&self) -> Option<&'static str> {
437        self.image_placeholder_template
438    }
439
440    fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
441        let mixins = Value::from_dyn_object(self.mixins.clone());
442
443        let tools = req.tools();
444        // Strip tools when tool_choice is "none" and the flag is enabled, so the model
445        // doesn't see tool definitions and generate raw XML tool calls in its response.
446        let tools = if self.exclude_tools_when_tool_choice_none {
447            match req.tool_choice() {
448                Some(ref tc) if tc.as_str() == Some("none") => None,
449                _ => tools,
450            }
451        } else {
452            tools
453        };
454        // has_tools should be true if tools is a non-empty array
455        let has_tools = tools.as_ref().and_then(|v| v.len()).is_some_and(|l| l > 0);
456        let add_generation_prompt = req.should_add_generation_prompt();
457
458        tracing::trace!(
459            "Rendering prompt with tools: {:?}, add_generation_prompt: {}",
460            has_tools,
461            add_generation_prompt
462        );
463
464        let messages_canonical = req.messages();
465        let mut messages_for_template: serde_json::Value =
466            serde_json::to_value(&messages_canonical).unwrap();
467
468        messages_for_template = serde_json::to_value(may_be_fix_msg_content(
469            messages_for_template,
470            self.requires_content_arrays,
471            self.image_placeholder_template,
472        ))
473        .unwrap();
474
475        // Pick the concrete template first so the normalization opt-out can be
476        // template- and field-specific: only the chosen template's
477        // `tool_call.arguments is string` flag should suppress normalization,
478        // and only for the `tool_calls[].function.arguments` field. Legacy
479        // `function_call.arguments` lives outside that branch and is always
480        // normalized.
481        let (template_name, template_handles_tool_calls_args_string, template_handles_reasoning) =
482            if has_tools {
483                (
484                    "tool_use",
485                    self.tool_use_template_handles_tool_calls_arguments_string,
486                    self.tool_use_template_handles_reasoning,
487                )
488            } else {
489                (
490                    "default",
491                    self.default_template_handles_tool_calls_arguments_string,
492                    self.default_template_handles_reasoning,
493                )
494            };
495
496        // Pre-parse JSON-string `arguments` into objects — but only for templates
497        // that unconditionally `| tojson` them. Templates that branch on
498        // `tool_call.arguments is string` (Qwen3, Hermes) want the raw string
499        // verbatim so the rendered bytes match what the model emitted on the
500        // prior turn. Re-serializing through minijinja's compact `tojson` here
501        // breaks append-only prefix matching across multi-step tool use.
502        if !template_handles_tool_calls_args_string {
503            normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
504        }
505        // Legacy `function_call.arguments` is always normalized — the
506        // `arguments is string` opt-out only covers the modern `tool_calls`
507        // branch.
508        normalize_function_call_arguments_in_messages(&mut messages_for_template);
509
510        // Inject reasoning_content as <think> blocks into content — but only if
511        // the template doesn't handle it natively. Templates like Nemotron and
512        // Qwen3 reference reasoning_content directly in their Jinja logic; injecting
513        // would produce duplicate <think> blocks.
514        if !template_handles_reasoning {
515            inject_reasoning_content_into_messages(&mut messages_for_template);
516        }
517
518        let ctx = context! {
519            messages => messages_for_template,
520            tools => tools,
521            bos_token => self.config.bos_tok(),
522            eos_token => self.config.eos_tok(),
523            unk_token => self.config.unk_tok(),
524            add_generation_prompt => add_generation_prompt,
525            ..mixins
526        };
527
528        // Merge any additional args into the context last so they take precedence
529        let ctx = if let Some(args) = req.chat_template_args() {
530            let extra = Value::from_serialize(args);
531            context! { ..ctx, ..extra }
532        } else {
533            ctx
534        };
535
536        let tmpl: minijinja::Template<'_, '_> = self.env.get_template(template_name)?;
537        Ok(tmpl.render(&ctx)?)
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use dynamo_protocols::types::ChatCompletionRequestMessage as Msg;
545    // The crate's renderer tests exercise the bare-protocol request type via the
546    // default `OAIChatLikeRequest` impl above; Dynamo's `Nv*` wrapper lives in lib/llm.
547    use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
548    use minijinja::{Environment, context};
549
550    /// End-to-end guard for the minijinja stack-overflow fix, exercised through
551    /// Dynamo's real chat-template render path. A template that accumulates
552    /// messages via `ns.items = ns.items + [m]` and then takes `|length`
553    /// previously overflowed the native stack for long conversations (~1500+
554    /// turns on a worker thread), core-dumping the frontend. Runs on a 2 MiB
555    /// stack — the size of a Dynamo tokio worker thread — so a regression aborts
556    /// deterministically instead of depending on the platform default.
557    #[test]
558    fn test_render_long_conversation_does_not_overflow_stack() {
559        let handle = std::thread::Builder::new()
560            .stack_size(2 * 1024 * 1024)
561            .spawn(|| {
562                let template_string = concat!(
563                    "{%- set ns = namespace(items=[]) -%}",
564                    "{%- for m in messages -%}",
565                    "{%- set ns.items = ns.items + [m] -%}",
566                    "{%- endfor -%}",
567                    "COUNT={{ ns.items | length }}"
568                );
569                let chat_template: ChatTemplate =
570                    serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
571                        .unwrap();
572                let formatter =
573                    HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[]))
574                        .unwrap();
575
576                let n = 3000;
577                let messages: Vec<serde_json::Value> = (0..n)
578                    .map(|i| serde_json::json!({"role": "user", "content": format!("turn {i}")}))
579                    .collect();
580                let request: NvCreateChatCompletionRequest =
581                    serde_json::from_value(serde_json::json!({
582                        "model": "test",
583                        "messages": messages,
584                    }))
585                    .unwrap();
586
587                // The crash path: `|length` -> minijinja `Value::len()`.
588                let rendered = formatter.render(&request).unwrap();
589                assert_eq!(rendered.trim(), format!("COUNT={n}"));
590            })
591            .unwrap();
592        handle.join().unwrap();
593    }
594
595    /// Dev utility (ignored by default): dump the prompt Dynamo's renderer
596    /// produces for a tool-calling chat request, so it can be diffed against
597    /// vLLM's `openai_harmony` rendering — to see whether the gpt-oss Jinja
598    /// `chat_template` actually emits the harmony "tool calls go to the
599    /// commentary channel" guidance + `functions` namespace.
600    ///
601    /// Point GPTOSS_CHAT_TEMPLATE at the model's tokenizer_config.json (its
602    /// `chat_template` field is extracted) OR a raw chat_template.jinja file:
603    ///   GPTOSS_CHAT_TEMPLATE=/path/openai-gpt-oss-120b/tokenizer_config.json \
604    ///     cargo test -p dynamo-renderer dump_gptoss_tool_prompt -- --ignored --nocapture
605    #[test]
606    #[ignore]
607    fn dump_gptoss_tool_prompt() {
608        use super::tokcfg::ChatTemplate;
609        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
610
611        let path = std::env::var("GPTOSS_CHAT_TEMPLATE").expect(
612            "set GPTOSS_CHAT_TEMPLATE to the tokenizer_config.json, chat_template.jinja, or model dir path",
613        );
614        let input_path = std::path::Path::new(&path);
615        let file_path = if input_path.is_dir() {
616            // Prefer tokenizer_config.json from model dir if provided
617            input_path.join("tokenizer_config.json")
618        } else {
619            input_path.to_path_buf()
620        };
621        let raw = std::fs::read_to_string(&file_path).expect("read chat template file");
622        // Resolve the actual Jinja template. gpt-oss ships its chat template in a
623        // separate `chat_template.jinja` file, NOT inside tokenizer_config.json,
624        // so:
625        //   * if the file is JSON with a `chat_template` field, use it;
626        //   * otherwise, if a sibling `chat_template.jinja` exists, read that;
627        //   * otherwise treat the file itself as the template.
628        let template_string: String = match serde_json::from_str::<serde_json::Value>(&raw) {
629            Ok(v) if v.get("chat_template").is_some() => v["chat_template"]
630                .as_str()
631                .expect("chat_template field must be a string")
632                .to_string(),
633            _ => {
634                let sibling = std::path::Path::new(&path)
635                    .parent()
636                    .map(|d| d.join("chat_template.jinja"));
637                match sibling {
638                    Some(p) if p.exists() => {
639                        eprintln!(
640                            "[info] {path} had no chat_template field; using {}",
641                            p.display()
642                        );
643                        std::fs::read_to_string(&p).expect("read sibling chat_template.jinja")
644                    }
645                    _ => raw,
646                }
647            }
648        };
649
650        // Guard against silently echoing a non-template (e.g. a tokenizer_config.json
651        // with no chat_template and no sibling .jinja).
652        assert!(
653            template_string.contains("{%") || template_string.contains("{{"),
654            "resolved template has no Jinja tags — GPTOSS_CHAT_TEMPLATE ({path}) is probably \
655             tokenizer_config.json with no chat_template field and no sibling chat_template.jinja. \
656             Point it at the chat_template.jinja file."
657        );
658
659        let chat_template: ChatTemplate =
660            serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
661                .unwrap();
662
663        let formatter =
664            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
665
666        // Declare tools — the tool-channel guidance only renders when tools are present.
667        let request: NvCreateChatCompletionRequest = serde_json::from_str(
668            r#"{
669              "model": "openai/gpt-oss-120b",
670              "messages": [{"role":"user","content":"Search the repo for the string \"countHook\"."}],
671              "tools": [
672                {"type":"function","function":{"name":"grep","description":"search files","parameters":{"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}}},
673                {"type":"function","function":{"name":"read","description":"read a file","parameters":{"type":"object","properties":{"filePath":{"type":"string"}},"required":["filePath"]}}}
674              ]
675            }"#,
676        )
677        .unwrap();
678
679        let rendered = formatter.render(&request).unwrap();
680        eprintln!("================ RENDERED gpt-oss PROMPT (tools declared) ================");
681        eprintln!("{rendered}");
682        eprintln!("================ END RENDERED PROMPT ================");
683        eprintln!("[diagnostics] does the rendered prompt contain…");
684        for needle in [
685            "commentary",
686            "Calls to these tools",
687            "functions",
688            "# Tools",
689            "<|channel|>",
690            "constrain",
691            "analysis",
692        ] {
693            eprintln!(
694                "  {:>22}: {}",
695                format!("{needle:?}"),
696                rendered.contains(needle)
697            );
698        }
699    }
700
701    /// Tests that media URL content parts are converted to empty placeholders.
702    #[test]
703    fn test_convert_media_url_to_placeholder_single_type() {
704        let content_array = vec![
705            serde_json::json!({"type": "text", "text": "Check this image:"}),
706            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
707            serde_json::json!({"type": "text", "text": "What do you see?"}),
708        ];
709
710        let conversions = &[("image_url", "image")];
711        let result = convert_media_url_to_placeholder(&content_array, conversions);
712
713        assert_eq!(result.len(), 3);
714        // Text parts should be unchanged
715        assert_eq!(result[0]["type"], "text");
716        assert_eq!(result[0]["text"], "Check this image:");
717        // image_url should be converted to image placeholder
718        assert_eq!(result[1]["type"], "image");
719        assert!(result[1].get("image_url").is_none());
720        // Text parts should be unchanged
721        assert_eq!(result[2]["type"], "text");
722        assert_eq!(result[2]["text"], "What do you see?");
723    }
724
725    /// Tests that multiple media URL parts of the same type are all converted.
726    #[test]
727    fn test_convert_media_url_to_placeholder_multiple_same_type() {
728        let content_array = vec![
729            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}}),
730            serde_json::json!({"type": "text", "text": "vs"}),
731            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}),
732        ];
733
734        let conversions = &[("image_url", "image")];
735        let result = convert_media_url_to_placeholder(&content_array, conversions);
736
737        assert_eq!(result.len(), 3);
738        assert_eq!(result[0]["type"], "image");
739        assert_eq!(result[1]["type"], "text");
740        assert_eq!(result[2]["type"], "image");
741    }
742
743    /// Tests that only specified media types are converted, others preserved.
744    #[test]
745    fn test_convert_media_url_to_placeholder_selective_conversion() {
746        let content_array = vec![
747            serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
748            serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
749            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
750        ];
751
752        // Only convert image_url
753        let conversions = &[("image_url", "image")];
754        let result = convert_media_url_to_placeholder(&content_array, conversions);
755
756        assert_eq!(result.len(), 3);
757        // audio_url and video_url should be preserved as-is
758        assert_eq!(result[0]["type"], "audio_url");
759        assert!(result[0].get("audio_url").is_some());
760        assert_eq!(result[1]["type"], "video_url");
761        assert!(result[1].get("video_url").is_some());
762        // Only image_url should be converted
763        assert_eq!(result[2]["type"], "image");
764        assert!(result[2].get("image_url").is_none());
765    }
766
767    /// Tests converting multiple different media types at once.
768    #[test]
769    fn test_convert_media_url_to_placeholder_multiple_types() {
770        let content_array = vec![
771            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
772            serde_json::json!({"type": "text", "text": "and listen to"}),
773            serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
774            serde_json::json!({"type": "text", "text": "and watch"}),
775            serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
776        ];
777
778        // Convert all media types
779        let conversions = &[
780            ("image_url", "image"),
781            ("audio_url", "audio"),
782            ("video_url", "video"),
783        ];
784        let result = convert_media_url_to_placeholder(&content_array, conversions);
785
786        assert_eq!(result.len(), 5);
787        assert_eq!(result[0]["type"], "image");
788        assert!(result[0].get("image_url").is_none());
789        assert_eq!(result[1]["type"], "text");
790        assert_eq!(result[2]["type"], "audio");
791        assert!(result[2].get("audio_url").is_none());
792        assert_eq!(result[3]["type"], "text");
793        assert_eq!(result[4]["type"], "video");
794        assert!(result[4].get("video_url").is_none());
795    }
796
797    /// Tests that empty conversions list preserves all content.
798    #[test]
799    fn test_convert_media_url_to_placeholder_no_conversions() {
800        let content_array = vec![
801            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
802            serde_json::json!({"type": "text", "text": "hello"}),
803        ];
804
805        let conversions: &[(&str, &str)] = &[];
806        let result = convert_media_url_to_placeholder(&content_array, conversions);
807
808        assert_eq!(result.len(), 2);
809        // Everything should be preserved as-is
810        assert_eq!(result[0]["type"], "image_url");
811        assert!(result[0].get("image_url").is_some());
812        assert_eq!(result[1]["type"], "text");
813    }
814
815    /// Tests that DEFAULT_MEDIA_TYPE_CONVERSIONS only converts image_url,
816    /// and preserves other media types like video_url and audio_url.
817    #[test]
818    fn test_default_media_type_conversions_only_converts_image_url() {
819        let content_array = vec![
820            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
821            serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
822            serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
823            serde_json::json!({"type": "text", "text": "hello"}),
824        ];
825
826        // Use the actual DEFAULT_MEDIA_TYPE_CONVERSIONS
827        let result =
828            convert_media_url_to_placeholder(&content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
829
830        assert_eq!(result.len(), 4);
831
832        // image_url SHOULD be converted to image (it's in the default map)
833        assert_eq!(result[0]["type"], "image");
834        assert!(result[0].get("image_url").is_none());
835
836        // video_url should NOT be converted (not in the default map)
837        assert_eq!(result[1]["type"], "video");
838        assert!(result[1].get("video_url").is_none());
839
840        // audio_url should NOT be converted (not in the default map)
841        assert_eq!(result[2]["type"], "audio");
842        assert!(result[2].get("audio_url").is_none());
843
844        // text should be unchanged
845        assert_eq!(result[3]["type"], "text");
846        assert_eq!(result[3]["text"], "hello");
847    }
848
849    #[test]
850    fn test_may_be_fix_tool_schema_missing_type_and_properties() {
851        let json_str = r#"{
852            "model": "gpt-4o",
853            "messages": [],
854            "tools": [
855                {
856                    "type": "function",
857                    "function": {
858                        "name": "get_weather",
859                        "description": "Get the current weather in a given location",
860                        "parameters": {},
861                        "strict": null
862                    }
863                }
864            ]
865        }"#;
866
867        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
868        let tools = serde_json::to_value(request.tools()).unwrap();
869
870        assert!(tools[0]["function"]["parameters"]["type"] == "object");
871        assert!(
872            tools[0]["function"]["parameters"]["properties"]
873                == serde_json::Value::Object(Default::default())
874        );
875    }
876
877    #[test]
878    fn test_may_be_fix_tool_schema_missing_type() {
879        let json_str = r#"{
880            "model": "gpt-4o",
881            "messages": [],
882            "tools": [
883                {
884                    "type": "function",
885                    "function": {
886                        "name": "get_weather",
887                        "description": "Get the current weather in a given location",
888                        "parameters": {
889                            "properties": {
890                                "location": {
891                                    "type": "string",
892                                    "description": "City and state, e.g., 'San Francisco, CA'"
893                                }
894                            }
895                        },
896                        "strict": null
897                    }
898                }
899            ]
900        }"#;
901        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
902
903        let tools = serde_json::to_value(request.tools()).unwrap();
904
905        assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
906
907        let mut expected_properties = serde_json::Map::new();
908        let mut location = serde_json::Map::new();
909        location.insert(
910            "type".to_string(),
911            serde_json::Value::String("string".to_string()),
912        );
913        location.insert(
914            "description".to_string(),
915            serde_json::Value::String("City and state, e.g., 'San Francisco, CA'".to_string()),
916        );
917        expected_properties.insert("location".to_string(), serde_json::Value::Object(location));
918
919        assert_eq!(
920            tools[0]["function"]["parameters"]["properties"],
921            serde_json::Value::Object(expected_properties)
922        );
923    }
924
925    #[test]
926    fn test_may_be_fix_tool_schema_missing_properties() {
927        let json_str = r#"{
928            "model": "gpt-4o",
929            "messages": [],
930            "tools": [
931                {
932                    "type": "function",
933                    "function": {
934                        "name": "get_weather",
935                        "description": "Get the current weather in a given location",
936                        "parameters": {"type": "object"},
937                        "strict": null
938                    }
939                }
940            ]
941        }"#;
942
943        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
944        let tools = serde_json::to_value(request.tools()).unwrap();
945
946        assert_eq!(
947            tools[0]["function"]["parameters"]["properties"],
948            serde_json::Value::Object(Default::default())
949        );
950        assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
951    }
952
953    #[test]
954    fn test_may_be_fix_tool_schema_missing_description() {
955        // `description` is optional in the OpenAI tool schema, but some chat
956        // templates (e.g. gpt-oss harmony) concatenate it unconditionally and
957        // fail on an `undefined`/null value. It must be backfilled to "".
958        let json_str = r#"{
959            "model": "gpt-4o",
960            "messages": [],
961            "tools": [
962                {
963                    "type": "function",
964                    "function": {
965                        "name": "noop",
966                        "parameters": {
967                            "type": "object",
968                            "properties": { "x": { "type": "string" } },
969                            "required": ["x"],
970                            "additionalProperties": false
971                        },
972                        "strict": null
973                    }
974                }
975            ]
976        }"#;
977
978        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
979        let tools = serde_json::to_value(request.tools()).unwrap();
980
981        assert_eq!(
982            tools[0]["function"]["description"],
983            serde_json::Value::String(String::new())
984        );
985    }
986
987    #[test]
988    fn test_may_be_fix_tool_schema_null_description() {
989        // An explicit null `description` must also be normalized to "".
990        let json_str = r#"{
991            "model": "gpt-4o",
992            "messages": [],
993            "tools": [
994                {
995                    "type": "function",
996                    "function": {
997                        "name": "noop",
998                        "description": null,
999                        "parameters": {"type": "object", "properties": {}},
1000                        "strict": null
1001                    }
1002                }
1003            ]
1004        }"#;
1005
1006        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1007        let tools = serde_json::to_value(request.tools()).unwrap();
1008
1009        assert_eq!(
1010            tools[0]["function"]["description"],
1011            serde_json::Value::String(String::new())
1012        );
1013    }
1014
1015    #[test]
1016    fn test_may_be_fix_tool_schema_preserves_description() {
1017        // A present `description` must be left untouched.
1018        let json_str = r#"{
1019            "model": "gpt-4o",
1020            "messages": [],
1021            "tools": [
1022                {
1023                    "type": "function",
1024                    "function": {
1025                        "name": "get_weather",
1026                        "description": "Get the current weather in a given location",
1027                        "parameters": {"type": "object", "properties": {}},
1028                        "strict": null
1029                    }
1030                }
1031            ]
1032        }"#;
1033
1034        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1035        let tools = serde_json::to_value(request.tools()).unwrap();
1036
1037        assert_eq!(
1038            tools[0]["function"]["description"],
1039            "Get the current weather in a given location"
1040        );
1041    }
1042
1043    /// Tests that content arrays (containing only text parts) are correctly concatenated.
1044    #[test]
1045    fn test_may_be_fix_msg_content_user_multipart() {
1046        let json_str = r#"{
1047            "model": "gpt-4o",
1048            "messages": [
1049                {
1050                    "role": "user",
1051                    "content": [
1052                        {"type": "text", "text": "part 1"},
1053                        {"type": "text", "text": "part 2"}
1054                    ]
1055                }
1056            ]
1057        }"#;
1058
1059        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1060        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1061
1062        // Test array → string normalization (preserve_arrays=false for standard templates)
1063        let messages =
1064            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1065
1066        // Verify: text-only array is concatenated into a single string
1067        assert_eq!(
1068            messages[0]["content"],
1069            serde_json::Value::String("part 1\npart 2".to_string())
1070        );
1071    }
1072
1073    /// Tests that the function correctly handles a conversation
1074    /// with multiple roles and mixed message types:
1075    #[test]
1076    fn test_may_be_fix_msg_content_mixed_messages() {
1077        let json_str = r#"{
1078            "model": "gpt-4o",
1079            "messages": [
1080                {
1081                    "role": "system",
1082                    "content": "You are a helpful assistant"
1083                },
1084                {
1085                    "role": "user",
1086                    "content": [
1087                        {"type": "text", "text": "Hello"},
1088                        {"type": "text", "text": "World"}
1089                    ]
1090                },
1091                {
1092                    "role": "assistant",
1093                    "content": "Hi there!"
1094                },
1095                {
1096                    "role": "user",
1097                    "content": [
1098                        {"type": "text", "text": "Another"},
1099                        {"type": "text", "text": "multi-part"},
1100                        {"type": "text", "text": "message"}
1101                    ]
1102                }
1103            ]
1104        }"#;
1105
1106        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1107        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1108
1109        // Test array → string normalization (preserve_arrays=false for standard templates)
1110        let messages =
1111            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1112
1113        // Verify: System message with string content remains unchanged
1114        assert_eq!(
1115            messages[0]["content"],
1116            serde_json::Value::String("You are a helpful assistant".to_string())
1117        );
1118
1119        // Verify: User message with text-only array is concatenated
1120        assert_eq!(
1121            messages[1]["content"],
1122            serde_json::Value::String("Hello\nWorld".to_string())
1123        );
1124
1125        // Verify: Assistant message with string content remains unchanged
1126        assert_eq!(
1127            messages[2]["content"],
1128            serde_json::Value::String("Hi there!".to_string())
1129        );
1130
1131        // Verify: Second user message with text-only array is concatenated
1132        assert_eq!(
1133            messages[3]["content"],
1134            serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
1135        );
1136    }
1137
1138    /// Tests that empty content arrays remain unchanged.
1139    #[test]
1140    fn test_may_be_fix_msg_content_empty_array() {
1141        let json_str = r#"{
1142            "model": "gpt-4o",
1143            "messages": [
1144                {
1145                    "role": "user",
1146                    "content": []
1147                }
1148            ]
1149        }"#;
1150
1151        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1152        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1153
1154        // Empty arrays should be preserved regardless of preserve_arrays setting
1155        let messages =
1156            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1157
1158        // Verify: Empty arrays are preserved as-is
1159        assert!(messages[0]["content"].is_array());
1160        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1161    }
1162
1163    /// Empty arrays must stay as `[]` even when a flatten-time placeholder
1164    /// template is provided (Phi-3 / LLaVA-1.5 path). Without the
1165    /// `!content_array.is_empty()` guard in `may_be_fix_msg_content`,
1166    /// an empty content array would silently flatten to `""` and the
1167    /// chat template would render an entirely empty message instead of
1168    /// failing or being preserved.
1169    #[test]
1170    fn test_may_be_fix_msg_content_empty_array_with_placeholder_template() {
1171        let json_str = r#"{
1172            "model": "phi-3-vision",
1173            "messages": [
1174                {
1175                    "role": "user",
1176                    "content": []
1177                }
1178            ]
1179        }"#;
1180
1181        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1182        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1183
1184        // preserve_arrays=false + image_placeholder_template=Some(...) is
1185        // the combination that previously flattened `[]` to `""`.
1186        let messages = serde_json::to_value(may_be_fix_msg_content(
1187            messages_raw,
1188            false,
1189            Some("<|image_{n}|>"),
1190        ))
1191        .unwrap();
1192
1193        assert!(
1194            messages[0]["content"].is_array(),
1195            "empty array should be preserved as `[]`, not flattened to `\"\"`"
1196        );
1197        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1198    }
1199
1200    /// Tests that messages with simple string content remain unchanged.
1201    #[test]
1202    fn test_may_be_fix_msg_content_single_text() {
1203        let json_str = r#"{
1204            "model": "gpt-4o",
1205            "messages": [
1206                {
1207                    "role": "user",
1208                    "content": "Simple text message"
1209                }
1210            ]
1211        }"#;
1212
1213        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1214        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1215
1216        // Test with preserve_arrays=false (standard templates)
1217        let messages =
1218            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1219
1220        // Verify: String content is not modified
1221        assert_eq!(
1222            messages[0]["content"],
1223            serde_json::Value::String("Simple text message".to_string())
1224        );
1225    }
1226
1227    /// Tests that content arrays with mixed types (text + non-text) remain as arrays,
1228    /// and that image_url is converted to image placeholder.
1229    #[test]
1230    fn test_may_be_fix_msg_content_mixed_types() {
1231        let json_str = r#"{
1232            "model": "gpt-4o",
1233            "messages": [
1234                {
1235                    "role": "user",
1236                    "content": [
1237                        {"type": "text", "text": "Check this image:"},
1238                        {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
1239                        {"type": "text", "text": "What do you see?"}
1240                    ]
1241                }
1242            ]
1243        }"#;
1244
1245        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1246        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1247
1248        // Mixed content should be preserved regardless of preserve_arrays setting
1249        let messages =
1250            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1251
1252        // Verify: Mixed content types are preserved as array for template handling
1253        // image_url should be converted to image placeholder
1254        assert!(messages[0]["content"].is_array());
1255        let content_array = messages[0]["content"].as_array().unwrap();
1256        assert_eq!(content_array.len(), 3);
1257        assert_eq!(content_array[0]["type"], "text");
1258        assert_eq!(content_array[1]["type"], "image");
1259        assert!(content_array[1].get("image_url").is_none());
1260        assert_eq!(content_array[2]["type"], "text");
1261    }
1262
1263    /// Mixed text+image array with a string-content template and an
1264    /// `<|image_{n}|>`-style placeholder (Phi-3-vision) — content must be
1265    /// flattened to a single string with numbered image markers in place of
1266    /// the image parts. The previous default (leave as array) would crash
1267    /// the Phi-3 template's `'+' message.content` concatenation.
1268    #[test]
1269    fn test_may_be_fix_msg_content_flattens_phi3_style() {
1270        let json_str = r#"{
1271            "model": "phi-3-vision",
1272            "messages": [
1273                {
1274                    "role": "user",
1275                    "content": [
1276                        {"type": "text", "text": "First "},
1277                        {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}},
1278                        {"type": "text", "text": " then "},
1279                        {"type": "image_url", "image_url": {"url": "https://example.com/b.jpg"}},
1280                        {"type": "text", "text": "?"}
1281                    ]
1282                }
1283            ]
1284        }"#;
1285        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1286        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1287
1288        let messages = serde_json::to_value(may_be_fix_msg_content(
1289            messages_raw,
1290            false,
1291            Some("<|image_{n}|>"),
1292        ))
1293        .unwrap();
1294
1295        let content = messages[0]["content"].as_str().expect("content flattened");
1296        assert_eq!(content, "First <|image_1|> then <|image_2|>?");
1297    }
1298
1299    /// Same flattening with a static placeholder (LLaVA-1.5 `<image>`).
1300    #[test]
1301    fn test_may_be_fix_msg_content_flattens_llava_style() {
1302        let json_str = r#"{
1303            "model": "llava-1.5-7b-hf",
1304            "messages": [
1305                {
1306                    "role": "user",
1307                    "content": [
1308                        {"type": "text", "text": "Describe: "},
1309                        {"type": "image_url", "image_url": {"url": "https://example.com/x.jpg"}}
1310                    ]
1311                }
1312            ]
1313        }"#;
1314        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1315        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1316
1317        let messages =
1318            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some("<image>")))
1319                .unwrap();
1320
1321        let content = messages[0]["content"].as_str().expect("content flattened");
1322        assert_eq!(content, "Describe: <image>");
1323    }
1324
1325    /// Nemotron-Parse pass-through path: a mixed text+image array with an
1326    /// empty placeholder (`""`) flattens to the text parts only — the image
1327    /// contributes nothing because the vision encoder consumes it out-of-band.
1328    /// Without this, `{{ message.content }}` would JSON-serialize the array
1329    /// into the prompt (the gibberish failure mode).
1330    #[test]
1331    fn test_may_be_fix_msg_content_flattens_empty_placeholder() {
1332        let json_str = r#"{
1333            "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1334            "messages": [
1335                {
1336                    "role": "user",
1337                    "content": [
1338                        {"type": "text", "text": "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"},
1339                        {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1340                    ]
1341                }
1342            ]
1343        }"#;
1344        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1345        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1346
1347        let messages =
1348            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some(""))).unwrap();
1349
1350        let content = messages[0]["content"].as_str().expect("content flattened");
1351        assert_eq!(
1352            content,
1353            "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
1354        );
1355    }
1356
1357    /// End-to-end render through the Nemotron-Parse pass-through chat template:
1358    /// a text+image chat request must produce exactly the control-token prompt,
1359    /// with the image dropped from the rendered text. The renderer is agnostic
1360    /// to the control tokens themselves (they are just the text part, passed
1361    /// through verbatim), so both `predict_no_text_in_pic` and
1362    /// `predict_text_in_pic` prompts round-trip identically.
1363    #[test]
1364    fn test_render_nemotron_parse_passthrough() {
1365        use super::super::tokcfg::ChatTemplate;
1366        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1367
1368        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1369            "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}"
1370        }))
1371        .unwrap();
1372        let formatter =
1373            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1374
1375        for prompt in [
1376            "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>",
1377            "</s><s><predict_bbox><predict_classes><output_markdown><predict_text_in_pic>",
1378        ] {
1379            let request: NvCreateChatCompletionRequest =
1380                serde_json::from_value(serde_json::json!({
1381                    "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1382                    "messages": [{
1383                        "role": "user",
1384                        "content": [
1385                            {"type": "text", "text": prompt},
1386                            {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1387                        ]
1388                    }]
1389                }))
1390                .unwrap();
1391
1392            let rendered = formatter.render(&request).unwrap();
1393            assert_eq!(
1394                rendered, prompt,
1395                "rendered prompt must be the control tokens only, with no JSON-serialized image array"
1396            );
1397        }
1398    }
1399
1400    /// Tests that content arrays containing only non-text types remain as arrays,
1401    /// and image_url types are converted to image placeholders.
1402    #[test]
1403    fn test_may_be_fix_msg_content_non_text_only() {
1404        let json_str = r#"{
1405            "model": "gpt-4o",
1406            "messages": [
1407                {
1408                    "role": "user",
1409                    "content": [
1410                        {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}},
1411                        {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}
1412                    ]
1413                }
1414            ]
1415        }"#;
1416
1417        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1418        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1419
1420        // Non-text arrays should be preserved regardless of preserve_arrays setting
1421        let messages =
1422            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1423
1424        // Verify: Non-text content arrays are preserved, with image_url converted to image
1425        assert!(messages[0]["content"].is_array());
1426        let content_array = messages[0]["content"].as_array().unwrap();
1427        assert_eq!(content_array.len(), 2);
1428        assert_eq!(content_array[0]["type"], "image");
1429        assert_eq!(content_array[1]["type"], "image");
1430    }
1431
1432    #[test]
1433    fn test_none_tools_safe_for_all_templates() {
1434        use super::tokcfg::ChatTemplate;
1435        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1436
1437        // Due to minijinja limitations the expressions in conditional statements may not be short-circuited
1438        // This checks that our custom length filter works to avoid errors in this scenario
1439        // length should return 0 if tools is None and 'if tools is iterable and tools | length > 0' should evaluate to false
1440        let length_template = r#"
1441{%- if tools is iterable and tools | length > 0 %}
1442Tools available: {{ tools | length }}
1443{%- else %}
1444No tools
1445{%- endif %}
1446"#;
1447
1448        // Because we return None for tools when there are no tools this scenario should also be evaluate to false
1449        // This is similar to the default jinja template behavior seen with llama models which check if tools is not none to activate tool mode
1450        let no_tool_template = r#"
1451{%- if tools is not none %}
1452TOOL MODE
1453{%- else %}
1454NORMAL MODE
1455{%- endif %}
1456"#;
1457
1458        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1459            "chat_template": [
1460                {"safe_length": length_template},
1461                {"no_tool": no_tool_template}
1462            ]
1463        }))
1464        .unwrap();
1465
1466        let formatter =
1467            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1468
1469        let ctx = context! { tools => Option::<Value>::None };
1470
1471        let result1 = formatter
1472            .env
1473            .get_template("safe_length")
1474            .unwrap()
1475            .render(&ctx);
1476        println!("Safe length template with no tools => None: {:?}", result1);
1477        assert!(
1478            result1.is_ok(),
1479            "Jinja template with and conditional and length filter should handle None: {:?}",
1480            result1
1481        );
1482        assert!(
1483            result1.unwrap().contains("No tools"),
1484            "Should show 'No tools'"
1485        );
1486
1487        let result2 = formatter.env.get_template("no_tool").unwrap().render(&ctx);
1488        println!("Default template with no tools => None: {:?}", result2);
1489        assert!(
1490            result2.is_ok(),
1491            "Jinja template with if tools is not none conditional should handle None: {:?}",
1492            result2
1493        );
1494        assert!(result2.unwrap().contains("NORMAL MODE"));
1495    }
1496
1497    /// Tests mixed content type scenarios.
1498    #[test]
1499    fn test_may_be_fix_msg_content_multiple_content_types() {
1500        // Scenario 1: Multiple different content types (text + image + audio)
1501        let json_str = r#"{
1502            "model": "gpt-4o",
1503            "messages": [
1504                {
1505                    "role": "user",
1506                    "content": [
1507                        {"type": "text", "text": "Listen to this:"},
1508                        {"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}},
1509                        {"type": "text", "text": "And look at:"},
1510                        {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
1511                        {"type": "text", "text": "What do you think?"}
1512                    ]
1513                }
1514            ]
1515        }"#;
1516
1517        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1518        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1519        let messages =
1520            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1521
1522        // Mixed types should preserve array structure, with image_url converted to image
1523        assert!(messages[0]["content"].is_array());
1524        let content_array = messages[0]["content"].as_array().unwrap();
1525        assert_eq!(content_array.len(), 5);
1526        assert_eq!(content_array[0]["type"], "text");
1527        assert_eq!(content_array[1]["type"], "audio");
1528        assert_eq!(content_array[2]["type"], "text");
1529        assert_eq!(content_array[3]["type"], "image");
1530        assert_eq!(content_array[4]["type"], "text");
1531
1532        // Scenario 2: Unknown/future content types mixed with text
1533        let json_str = r#"{
1534            "model": "gpt-4o",
1535            "messages": [
1536                {
1537                    "role": "user",
1538                    "content": [
1539                        {"type": "text", "text": "Check this:"},
1540                        {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1541                        {"type": "text", "text": "Interesting?"}
1542                    ]
1543                }
1544            ]
1545        }"#;
1546
1547        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1548        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1549        let messages =
1550            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1551
1552        // Unknown types mixed with text should preserve array
1553        assert!(messages[0]["content"].is_array());
1554        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1555    }
1556
1557    #[test]
1558    fn test_normalize_tool_arguments_tojson() {
1559        let tmpl = r#"{{ messages[0].tool_calls[0].function.arguments | tojson }}"#;
1560
1561        // Message with tool_calls containing JSON string arguments
1562        let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1563            "role": "assistant",
1564            "tool_calls": [{
1565                "type": "function",
1566                "function": {
1567                    "name": "get_current_weather",
1568                    "arguments": "{\"format\":\"celsius\",\"location\":\"San Francisco, CA\"}"
1569                }
1570            }]
1571        })]);
1572
1573        normalize_tool_calls_arguments_in_messages(&mut messages);
1574
1575        let mut env = Environment::new();
1576        env.add_filter("tojson", super::super::tokcfg::tojson);
1577        env.add_template("t", tmpl).unwrap();
1578        let out = env
1579            .get_template("t")
1580            .unwrap()
1581            .render(context! { messages => messages.as_array().unwrap() })
1582            .unwrap();
1583
1584        // Should produce clean JSON without double-encoding, with Python
1585        // json.dumps separators (what transformers' tojson emits).
1586        assert_eq!(
1587            out,
1588            r#"{"format": "celsius", "location": "San Francisco, CA"}"#
1589        );
1590    }
1591
1592    #[test]
1593    fn test_normalize_tool_arguments_items_loop() {
1594        let tmpl = r#"{% for k, v in messages[0].tool_calls[0].function.arguments|items %}{{k}}={{v}};{% endfor %}"#;
1595
1596        let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1597            "role": "assistant",
1598            "tool_calls": [{
1599                "type": "function",
1600                "function": {
1601                    "name": "f",
1602                    "arguments": "{\"a\":1,\"b\":\"x\"}"
1603                }
1604            }]
1605        })]);
1606
1607        normalize_tool_calls_arguments_in_messages(&mut messages);
1608
1609        let mut env = Environment::new();
1610        env.add_template("t", tmpl).unwrap();
1611        let out = env
1612            .get_template("t")
1613            .unwrap()
1614            .render(context! { messages => messages.as_array().unwrap() })
1615            .unwrap();
1616
1617        assert!(out == "a=1;b=x;" || out == "b=x;a=1;");
1618    }
1619
1620    #[test]
1621    fn test_normalize_tool_arguments_legacy_function_call() {
1622        // Test deprecated function_call format (OpenAI compat)
1623        let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1624            "role": "assistant",
1625            "function_call": {
1626                "name": "get_weather",
1627                "arguments": "{\"location\":\"NYC\"}"
1628            }
1629        })]);
1630
1631        normalize_function_call_arguments_in_messages(&mut messages);
1632
1633        assert_eq!(
1634            messages[0]["function_call"]["arguments"],
1635            serde_json::json!({"location": "NYC"})
1636        );
1637    }
1638
1639    #[test]
1640    fn test_normalize_tool_arguments_malformed_json_passthrough() {
1641        // Malformed JSON should be left as a string
1642        let mut messages = serde_json::Value::Array(vec![serde_json::json!({
1643            "role": "assistant",
1644            "tool_calls": [{
1645                "type": "function",
1646                "function": {
1647                    "name": "f",
1648                    "arguments": "not valid json at all"
1649                }
1650            }]
1651        })]);
1652
1653        normalize_tool_calls_arguments_in_messages(&mut messages);
1654
1655        assert_eq!(
1656            messages[0]["tool_calls"][0]["function"]["arguments"],
1657            serde_json::Value::String("not valid json at all".to_string())
1658        );
1659    }
1660
1661    #[test]
1662    fn test_normalize_tool_arguments_with_multimodal_content() {
1663        let json_str = r#"{
1664            "model": "gpt-4o",
1665            "messages": [
1666                {
1667                    "role": "user",
1668                    "content": [
1669                        {"type": "text", "text": "Check this:"},
1670                        {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
1671                        {"type": "text", "text": "Interesting?"}
1672                    ]
1673                },
1674                {
1675                    "role": "assistant",
1676                    "tool_calls": [{
1677                        "id": "call_123",
1678                        "type": "function",
1679                        "function": {
1680                            "name": "analyze_video",
1681                            "arguments": "{\"url\":\"https://example.com/vid.mp4\",\"format\":\"mp4\"}"
1682                        }
1683                    }]
1684                }
1685            ]
1686        }"#;
1687
1688        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1689        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1690
1691        // Apply content normalization with preserve_arrays=false (standard templates)
1692        let mut messages =
1693            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1694
1695        normalize_tool_calls_arguments_in_messages(&mut messages);
1696
1697        // Multimodal content preserved as array (mixed types not flattened)
1698        assert!(messages[0]["content"].is_array());
1699        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
1700
1701        // Tool arguments deserialized to object
1702        assert!(messages[1]["tool_calls"][0]["function"]["arguments"].is_object());
1703        assert_eq!(
1704            messages[1]["tool_calls"][0]["function"]["arguments"]["url"],
1705            "https://example.com/vid.mp4"
1706        );
1707    }
1708
1709    /// Tests string → array normalization for multimodal templates
1710    #[test]
1711    fn test_may_be_fix_msg_content_string_to_array() {
1712        let json_str = r#"{
1713            "model": "gpt-4o",
1714            "messages": [
1715                {
1716                    "role": "user",
1717                    "content": "Hello, how are you?"
1718                }
1719            ]
1720        }"#;
1721
1722        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1723        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1724
1725        // Test with preserve_arrays=true (multimodal templates)
1726        let messages =
1727            serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1728
1729        // Verify: String is converted to array format
1730        assert!(messages[0]["content"].is_array());
1731        let content_array = messages[0]["content"].as_array().unwrap();
1732        assert_eq!(content_array.len(), 1);
1733        assert_eq!(content_array[0]["type"], "text");
1734        assert_eq!(content_array[0]["text"], "Hello, how are you?");
1735    }
1736
1737    /// Tests that arrays are preserved when preserve_arrays=true
1738    #[test]
1739    fn test_may_be_fix_msg_content_array_preserved_with_multimodal() {
1740        let json_str = r#"{
1741            "model": "gpt-4o",
1742            "messages": [
1743                {
1744                    "role": "user",
1745                    "content": [
1746                        {"type": "text", "text": "part 1"},
1747                        {"type": "text", "text": "part 2"}
1748                    ]
1749                }
1750            ]
1751        }"#;
1752
1753        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1754        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1755
1756        // Test with preserve_arrays=true (multimodal templates)
1757        let messages =
1758            serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
1759
1760        // Verify: Array is preserved as-is
1761        assert!(messages[0]["content"].is_array());
1762        let content_array = messages[0]["content"].as_array().unwrap();
1763        assert_eq!(content_array.len(), 2);
1764        assert_eq!(content_array[0]["text"], "part 1");
1765        assert_eq!(content_array[1]["text"], "part 2");
1766    }
1767
1768    fn user() -> Msg {
1769        Msg::User(Default::default())
1770    }
1771    fn tool() -> Msg {
1772        Msg::Tool(Default::default())
1773    }
1774
1775    fn dummy_state(messages: Vec<Msg>) -> NvCreateChatCompletionRequest {
1776        let json = serde_json::json!({
1777            "model": "test-model",
1778            "messages": messages
1779        });
1780        serde_json::from_value(json).unwrap()
1781    }
1782
1783    #[test]
1784    fn add_after_user() {
1785        let s = dummy_state(vec![user()]);
1786        assert!(s.should_add_generation_prompt());
1787    }
1788
1789    #[test]
1790    fn add_after_tool() {
1791        let s = dummy_state(vec![tool()]);
1792        assert!(s.should_add_generation_prompt());
1793    }
1794
1795    #[test]
1796    fn add_when_empty() {
1797        let s = dummy_state(vec![]);
1798        assert!(s.should_add_generation_prompt());
1799    }
1800
1801    /// Helper to build a formatter with a simple tool-aware template.
1802    fn tool_aware_formatter(
1803        exclude_tools_when_tool_choice_none: bool,
1804    ) -> HfTokenizerConfigJsonFormatter {
1805        let template = r#"
1806{%- if tools is iterable and tools | length > 0 %}
1807TOOL_MODE tools={{ tools | length }}
1808{%- else %}
1809NORMAL_MODE
1810{%- endif %}
1811{{ messages[0].content }}"#;
1812
1813        let chat_template: super::tokcfg::ChatTemplate =
1814            serde_json::from_value(serde_json::json!({ "chat_template": template })).unwrap();
1815
1816        HfTokenizerConfigJsonFormatter::with_options(
1817            chat_template,
1818            ContextMixins::new(&[]),
1819            exclude_tools_when_tool_choice_none,
1820        )
1821        .unwrap()
1822    }
1823
1824    fn gemma4_tool_template_for_tests() -> &'static str {
1825        r#"
1826{{ bos_token }}
1827{%- set loop_messages = messages -%}
1828{%- set ns_turn = namespace(last_user_idx=-1) -%}
1829{%- for i in range(loop_messages | length) -%}
1830    {%- if loop_messages[i]['role'] == 'user' -%}
1831        {%- set ns_turn.last_user_idx = i -%}
1832    {%- endif -%}
1833{%- endfor -%}
1834{%- for message in loop_messages -%}
1835    {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
1836    {{- '<|turn>' + role + '\n' }}
1837
1838    {%- if message.get('reasoning') and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
1839        {{- '<|channel>thought\n' + message['reasoning'] + '\n<channel|>'}}
1840    {%- endif -%}
1841
1842            {%- if message['tool_calls'] -%}
1843                {%- for tool_call in message['tool_calls'] -%}
1844                    {%- set function = tool_call['function'] -%}
1845                    {{- '<|tool_call>call:' + function['name'] + '{' -}}
1846                    {%- if function['arguments'] is mapping -%}
1847                        {%- set ns_args = namespace(found_first=false) -%}
1848                        {%- for key, value in function['arguments'] | dictsort -%}
1849                            {%- if ns_args.found_first %},{% endif -%}
1850                            {%- set ns_args.found_first = true -%}
1851                            {{- key -}}:{{- value -}}
1852                        {%- endfor -%}
1853                    {%- elif function['arguments'] is string -%}
1854                        {{- function['arguments'] -}}
1855                    {%- endif -%}
1856                    {{- '}<tool_call|>' -}}
1857                {%- endfor -%}
1858            {%- endif -%}
1859
1860            {%- if message['content'] is string -%}
1861                {{- message['content'] -}}
1862            {%- endif -%}
1863    {{- '<turn|>\n' -}}
1864{%- endfor -%}
1865"#
1866    }
1867
1868    fn make_gemma4_tool_formatter_for_tests() -> HfTokenizerConfigJsonFormatter {
1869        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
1870            "chat_template": gemma4_tool_template_for_tests()
1871        }))
1872        .unwrap();
1873        HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
1874    }
1875
1876    /// Helper to build a request with tools and optional tool_choice.
1877    fn request_with_tool_choice(tool_choice: &str) -> NvCreateChatCompletionRequest {
1878        serde_json::from_value(serde_json::json!({
1879            "model": "test",
1880            "messages": [{"role": "user", "content": "hello"}],
1881            "tools": [{
1882                "type": "function",
1883                "function": {
1884                    "name": "get_weather",
1885                    "description": "Get weather",
1886                    "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}
1887                }
1888            }],
1889            "tool_choice": tool_choice
1890        }))
1891        .unwrap()
1892    }
1893
1894    #[test]
1895    fn test_exclude_tools_strips_when_tool_choice_none() {
1896        let formatter = tool_aware_formatter(true);
1897        let request = request_with_tool_choice("none");
1898        let result = formatter.render(&request).unwrap();
1899        assert!(
1900            result.contains("NORMAL_MODE"),
1901            "With exclude_tools=true and tool_choice=none, tools should be stripped. Got: {}",
1902            result
1903        );
1904    }
1905
1906    #[test]
1907    fn test_exclude_tools_keeps_when_tool_choice_auto() {
1908        let formatter = tool_aware_formatter(true);
1909        let request = request_with_tool_choice("auto");
1910        let result = formatter.render(&request).unwrap();
1911        assert!(
1912            result.contains("TOOL_MODE"),
1913            "With tool_choice=auto, tools should be included. Got: {}",
1914            result
1915        );
1916    }
1917
1918    #[test]
1919    fn test_no_exclude_tools_keeps_when_tool_choice_none() {
1920        let formatter = tool_aware_formatter(false);
1921        let request = request_with_tool_choice("none");
1922        let result = formatter.render(&request).unwrap();
1923        assert!(
1924            result.contains("TOOL_MODE"),
1925            "With exclude_tools=false and tool_choice=none, tools should NOT be stripped. Got: {}",
1926            result
1927        );
1928    }
1929
1930    #[test]
1931    fn test_inject_reasoning_content_segments_with_tool_calls() {
1932        // Assistant message with reasoning_content segments and tool_calls
1933        let mut messages = serde_json::json!([
1934            {
1935                "role": "user",
1936                "content": "What is sqrt(144) and sqrt(256)?"
1937            },
1938            {
1939                "role": "assistant",
1940                "content": "Let me calculate those.",
1941                "reasoning_content": ["I need to compute sqrt(144)", "Now sqrt(256)", ""],
1942                "tool_calls": [
1943                    {
1944                        "id": "call_0",
1945                        "type": "function",
1946                        "function": {
1947                            "name": "calculator",
1948                            "arguments": "{\"expr\": \"sqrt(144)\"}"
1949                        }
1950                    },
1951                    {
1952                        "id": "call_1",
1953                        "type": "function",
1954                        "function": {
1955                            "name": "calculator",
1956                            "arguments": "{\"expr\": \"sqrt(256)\"}"
1957                        }
1958                    }
1959                ]
1960            }
1961        ]);
1962
1963        inject_reasoning_content_into_messages(&mut messages);
1964
1965        let assistant = &messages[1];
1966
1967        // reasoning_content should be removed
1968        assert!(
1969            assistant.get("reasoning_content").is_none(),
1970            "reasoning_content should be removed after injection"
1971        );
1972
1973        // content should have <think> blocks prepended (empty segment skipped)
1974        let content = assistant["content"].as_str().unwrap();
1975        assert!(
1976            content.starts_with("<think>I need to compute sqrt(144)</think>"),
1977            "content should start with first reasoning segment, got: {}",
1978            content
1979        );
1980        assert!(
1981            content.contains("<think>Now sqrt(256)</think>"),
1982            "content should contain second reasoning segment"
1983        );
1984        // Empty third segment should NOT produce <think></think>
1985        assert!(
1986            !content.contains("<think></think>"),
1987            "empty segments should be skipped"
1988        );
1989        // Original content should be preserved at the end
1990        assert!(
1991            content.ends_with("Let me calculate those."),
1992            "original content should be at the end, got: {}",
1993            content
1994        );
1995
1996        // tool_calls should be untouched
1997        assert!(assistant.get("tool_calls").is_some());
1998        assert_eq!(assistant["tool_calls"].as_array().unwrap().len(), 2);
1999    }
2000
2001    #[test]
2002    fn test_gemma4_template_renders_reasoning_content_segments_around_tool_calls() {
2003        let formatter = make_gemma4_tool_formatter_for_tests();
2004        assert!(
2005            formatter.tool_use_template_handles_reasoning,
2006            "Gemma4 template adaptation should make reasoning_content native"
2007        );
2008
2009        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2010            "model": "gemma4-test",
2011            "messages": [
2012                {"role": "user", "content": "inspect two things"},
2013                {
2014                    "role": "assistant",
2015                    "content": null,
2016                    "reasoning_content": [
2017                        "Think before the first call.",
2018                        "Think before the second call.",
2019                        "Think after both calls."
2020                    ],
2021                    "tool_calls": [
2022                        {
2023                            "id": "call_0",
2024                            "type": "function",
2025                            "function": {
2026                                "name": "first_tool",
2027                                "arguments": "{\"path\":\".\"}"
2028                            }
2029                        },
2030                        {
2031                            "id": "call_1",
2032                            "type": "function",
2033                            "function": {
2034                                "name": "second_tool",
2035                                "arguments": "{\"path\":\"/tmp\"}"
2036                            }
2037                        }
2038                    ]
2039                }
2040            ]
2041        }))
2042        .unwrap();
2043
2044        let rendered = formatter.render(&request).unwrap();
2045
2046        let expected = concat!(
2047            "<|channel>thought\nThink before the first call.\n<channel|>",
2048            "<|tool_call>call:first_tool{path:.}<tool_call|>",
2049            "<|channel>thought\nThink before the second call.\n<channel|>",
2050            "<|tool_call>call:second_tool{path:/tmp}<tool_call|>",
2051            "<|channel>thought\nThink after both calls.\n<channel|>"
2052        );
2053        assert!(
2054            rendered.contains(expected),
2055            "Gemma4 reasoning segments should stay adjacent to their tool calls, got: {rendered}"
2056        );
2057        assert!(!rendered.contains("<think>"));
2058        assert!(!rendered.contains("reasoning_content"));
2059    }
2060
2061    #[test]
2062    fn test_gemma4_template_renders_reasoning_content_without_tool_calls() {
2063        let formatter = make_gemma4_tool_formatter_for_tests();
2064        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2065            "model": "gemma4-test",
2066            "messages": [
2067                {"role": "user", "content": "answer directly"},
2068                {
2069                    "role": "assistant",
2070                    "content": "Direct answer.",
2071                    "reasoning_content": "Private thought."
2072                }
2073            ]
2074        }))
2075        .unwrap();
2076
2077        let rendered = formatter.render(&request).unwrap();
2078
2079        assert!(
2080            rendered.contains("<|channel>thought\nPrivate thought.\n<channel|>Direct answer."),
2081            "Gemma4 reasoning_content should render in the thought channel, got: {rendered}"
2082        );
2083        assert!(!rendered.contains("<think>"));
2084        assert!(!rendered.contains("reasoning_content"));
2085    }
2086
2087    /// Regression: when a config ships a separate non-tool `default` template
2088    /// (dict form), adapting only the `tool_use` template to read
2089    /// `reasoning_content` must NOT suppress `<think>` injection on the
2090    /// `default` path. A global `any()` flag would flip true off the adapted
2091    /// `tool_use` template and silently drop reasoning on no-tool renders.
2092    #[test]
2093    fn test_reasoning_flag_is_per_template_not_global() {
2094        // Plain default template: renders content, never mentions reasoning_content
2095        // and lacks the Gemma4 fingerprint, so it is left untouched.
2096        const PLAIN_DEFAULT: &str = "{{ bos_token }}{%- for message in messages -%}\
2097            {{ message['role'] }}: {{ message['content'] }}\n{%- endfor -%}";
2098
2099        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2100            "chat_template": [
2101                {"default": PLAIN_DEFAULT},
2102                {"tool_use": gemma4_tool_template_for_tests()},
2103            ]
2104        }))
2105        .unwrap();
2106        let formatter =
2107            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2108
2109        // The adapted Gemma4 tool_use template handles reasoning natively; the
2110        // untouched plain default template does not. The flag must reflect that
2111        // per-template split, not a global OR across both.
2112        assert!(
2113            formatter.tool_use_template_handles_reasoning,
2114            "adapted gemma4 tool_use template should handle reasoning natively"
2115        );
2116        assert!(
2117            !formatter.default_template_handles_reasoning,
2118            "plain default template does not reference reasoning_content"
2119        );
2120
2121        // A no-tools request routes to `default`. Reasoning must still be injected
2122        // as a <think> block — not silently dropped by the tool_use template's flag.
2123        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2124            "model": "gemma4-test",
2125            "messages": [
2126                {"role": "user", "content": "answer directly"},
2127                {
2128                    "role": "assistant",
2129                    "content": "Direct answer.",
2130                    "reasoning_content": "Private thought."
2131                }
2132            ]
2133        }))
2134        .unwrap();
2135
2136        let rendered = formatter.render(&request).unwrap();
2137        assert!(
2138            rendered.contains("<think>Private thought.</think>Direct answer."),
2139            "reasoning must be injected on the no-tool default path, got: {rendered}"
2140        );
2141    }
2142
2143    #[test]
2144    fn test_inject_reasoning_content_text_variant() {
2145        let mut messages = serde_json::json!([
2146            {
2147                "role": "assistant",
2148                "content": "The answer is 42.",
2149                "reasoning_content": "Let me think about this carefully."
2150            }
2151        ]);
2152
2153        inject_reasoning_content_into_messages(&mut messages);
2154
2155        let assistant = &messages[0];
2156        assert!(assistant.get("reasoning_content").is_none());
2157        let content = assistant["content"].as_str().unwrap();
2158        assert_eq!(
2159            content,
2160            "<think>Let me think about this carefully.</think>The answer is 42."
2161        );
2162    }
2163
2164    #[test]
2165    fn test_inject_reasoning_content_null_content() {
2166        // reasoning_content present but content is null
2167        let mut messages = serde_json::json!([
2168            {
2169                "role": "assistant",
2170                "content": null,
2171                "reasoning_content": "Thinking...",
2172                "tool_calls": [{"id": "call_0", "type": "function", "function": {"name": "f", "arguments": "{}"}}]
2173            }
2174        ]);
2175
2176        inject_reasoning_content_into_messages(&mut messages);
2177
2178        let content = messages[0]["content"].as_str().unwrap();
2179        assert_eq!(content, "<think>Thinking...</think>");
2180        assert!(messages[0].get("reasoning_content").is_none());
2181    }
2182
2183    #[test]
2184    fn test_inject_reasoning_content_skips_non_assistant() {
2185        let mut messages = serde_json::json!([
2186            {
2187                "role": "user",
2188                "content": "hello",
2189                "reasoning_content": "should not be touched"
2190            }
2191        ]);
2192
2193        inject_reasoning_content_into_messages(&mut messages);
2194
2195        // User message should be untouched
2196        assert!(messages[0].get("reasoning_content").is_some());
2197    }
2198
2199    // Helper: create a formatter with a minimal chat template for render tests
2200    fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
2201        use super::tokcfg::ChatTemplate;
2202        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2203
2204        // Minimal template that renders content verbatim — enough to verify
2205        // that reasoning_content injection works through the full pipeline.
2206        let template = r#"{%- for message in messages %}{{ message.role }}: {{ message.content }}
2207{%- endfor %}
2208{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2209
2210        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2211            "chat_template": template
2212        }))
2213        .unwrap();
2214
2215        HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2216    }
2217
2218    // Verify reasoning_content (Text variant) from a prior assistant turn
2219    // appears as a <think> block in the rendered prompt.
2220    #[test]
2221    fn test_reasoning_content_text_roundtrip_render() {
2222        use super::OAIPromptFormatter;
2223        let formatter = make_test_formatter();
2224
2225        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2226            "model": "test-model",
2227            "messages": [
2228                {"role": "user", "content": "What is sqrt(144)?"},
2229                {
2230                    "role": "assistant",
2231                    "content": "The answer is 12.",
2232                    "reasoning_content": "I need to compute the square root of 144."
2233                },
2234                {"role": "user", "content": "Are you sure?"}
2235            ]
2236        }))
2237        .unwrap();
2238
2239        let rendered = formatter.render(&request).unwrap();
2240
2241        assert!(
2242            rendered.contains("<think>I need to compute the square root of 144.</think>"),
2243            "reasoning_content must appear as <think> block, got: {}",
2244            rendered
2245        );
2246        assert!(
2247            rendered.contains("The answer is 12."),
2248            "original content must be preserved"
2249        );
2250        assert!(
2251            !rendered.contains("reasoning_content"),
2252            "raw reasoning_content field should not leak into prompt"
2253        );
2254    }
2255
2256    // Verify a full agentic flow: assistant reasons, calls a tool, gets a
2257    // result, then reasons again before answering. Both reasoning turns must
2258    // survive into the rendered prompt.
2259    #[test]
2260    fn test_reasoning_content_agentic_tool_call_roundtrip_render() {
2261        use super::OAIPromptFormatter;
2262        let formatter = make_test_formatter();
2263
2264        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2265            "model": "test-model",
2266            "messages": [
2267                {"role": "user", "content": "What is sqrt(144) + sqrt(256)?"},
2268                {
2269                    "role": "assistant",
2270                    "content": null,
2271                    "reasoning_content": "I need to compute both square roots. Let me start with sqrt(144).",
2272                    "tool_calls": [{
2273                        "id": "call_0",
2274                        "type": "function",
2275                        "function": {
2276                            "name": "calculator",
2277                            "arguments": "{\"expr\": \"sqrt(144)\"}"
2278                        }
2279                    }]
2280                },
2281                {
2282                    "role": "tool",
2283                    "tool_call_id": "call_0",
2284                    "content": "12"
2285                },
2286                {
2287                    "role": "assistant",
2288                    "content": "sqrt(144) = 12 and sqrt(256) = 16, so the answer is 28.",
2289                    "reasoning_content": "Got 12 for sqrt(144). Now sqrt(256) = 16. Sum is 28."
2290                },
2291                {"role": "user", "content": "Thanks!"}
2292            ]
2293        }))
2294        .unwrap();
2295
2296        let rendered = formatter.render(&request).unwrap();
2297
2298        // First assistant turn: reasoning with tool call, null content
2299        assert!(
2300            rendered.contains("<think>I need to compute both square roots"),
2301            "first turn reasoning must be in prompt, got: {}",
2302            rendered
2303        );
2304        // Second assistant turn: reasoning with final answer
2305        assert!(
2306            rendered.contains("<think>Got 12 for sqrt(144)"),
2307            "second turn reasoning must be in prompt"
2308        );
2309        assert!(
2310            rendered.contains("the answer is 28"),
2311            "final answer content must be preserved"
2312        );
2313        // No raw reasoning_content in output
2314        assert!(
2315            !rendered.contains("reasoning_content"),
2316            "raw reasoning_content field should not leak into prompt"
2317        );
2318    }
2319
2320    // Template that does NOT reference reasoning_content — injection should happen.
2321    #[test]
2322    fn test_reasoning_injected_when_template_ignores_it() {
2323        use super::OAIPromptFormatter;
2324        let formatter = make_test_formatter();
2325
2326        // Formatter uses a simple template that doesn't reference reasoning_content
2327        assert!(!formatter.default_template_handles_reasoning);
2328        assert!(!formatter.tool_use_template_handles_reasoning);
2329
2330        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2331            "model": "test-model",
2332            "messages": [
2333                {"role": "user", "content": "Hello"},
2334                {
2335                    "role": "assistant",
2336                    "content": "Hi.",
2337                    "reasoning_content": "The user said hello."
2338                },
2339                {"role": "user", "content": "Bye"}
2340            ]
2341        }))
2342        .unwrap();
2343
2344        let rendered = formatter.render(&request).unwrap();
2345        assert!(
2346            rendered.contains("<think>The user said hello.</think>"),
2347            "injection must happen when template ignores reasoning_content, got: {}",
2348            rendered
2349        );
2350    }
2351
2352    // Template that DOES reference reasoning_content — injection must be skipped.
2353    #[test]
2354    fn test_reasoning_not_injected_when_template_handles_it() {
2355        use super::tokcfg::ChatTemplate;
2356        use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
2357
2358        // Template that natively renders reasoning_content (like Nemotron/Qwen3)
2359        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>
2360{%- endif %}{{ message.role }}: {{ message.content }}
2361{%- endfor %}
2362{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2363
2364        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2365            "chat_template": template
2366        }))
2367        .unwrap();
2368
2369        let formatter =
2370            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2371
2372        // Verify detection worked
2373        assert!(formatter.default_template_handles_reasoning);
2374        assert!(formatter.tool_use_template_handles_reasoning);
2375
2376        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2377            "model": "test-model",
2378            "messages": [
2379                {"role": "user", "content": "Hello"},
2380                {
2381                    "role": "assistant",
2382                    "content": "Hi.",
2383                    "reasoning_content": "The user said hello."
2384                },
2385                {"role": "user", "content": "Bye"}
2386            ]
2387        }))
2388        .unwrap();
2389
2390        let rendered = formatter.render(&request).unwrap();
2391
2392        // Template renders reasoning natively — no duplicate injection
2393        assert!(
2394            rendered.contains("<think>The user said hello.</think>"),
2395            "template must render reasoning_content natively, got: {}",
2396            rendered
2397        );
2398        // Must NOT have double <think> blocks
2399        let think_count = rendered.matches("<think>").count();
2400        assert_eq!(
2401            think_count, 1,
2402            "must have exactly one <think> block (from template), got {} in: {}",
2403            think_count, rendered
2404        );
2405    }
2406
2407    /// Real Qwen3-4B-Thinking-2507 chat template (verbatim from
2408    /// `Qwen/Qwen3-4B-Thinking-2507/tokenizer_config.json`). Used to
2409    /// regression-test append-only rendering across multi-step tool use.
2410    const QWEN3_THINKING_TEMPLATE: &str = r##"{%- if tools %}
2411    {{- '<|im_start|>system\n' }}
2412    {%- if messages[0].role == 'system' %}
2413        {{- messages[0].content + '\n\n' }}
2414    {%- endif %}
2415    {{- "# 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>" }}
2416    {%- for tool in tools %}
2417        {{- "\n" }}
2418        {{- tool | tojson }}
2419    {%- endfor %}
2420    {{- "\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" }}
2421{%- else %}
2422    {%- if messages[0].role == 'system' %}
2423        {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
2424    {%- endif %}
2425{%- endif %}
2426{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
2427{%- for message in messages[::-1] %}
2428    {%- set index = (messages|length - 1) - loop.index0 %}
2429    {%- 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>')) %}
2430        {%- set ns.multi_step_tool = false %}
2431        {%- set ns.last_query_index = index %}
2432    {%- endif %}
2433{%- endfor %}
2434{%- for message in messages %}
2435    {%- if message.content is string %}
2436        {%- set content = message.content %}
2437    {%- else %}
2438        {%- set content = '' %}
2439    {%- endif %}
2440    {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
2441        {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
2442    {%- elif message.role == "assistant" %}
2443        {%- set reasoning_content = '' %}
2444        {%- if message.reasoning_content is string %}
2445            {%- set reasoning_content = message.reasoning_content %}
2446        {%- else %}
2447            {%- if '</think>' in content %}
2448                {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
2449                {%- set content = content.split('</think>')[-1].lstrip('\n') %}
2450            {%- endif %}
2451        {%- endif %}
2452        {%- if loop.index0 > ns.last_query_index %}
2453            {%- if loop.last or (not loop.last and reasoning_content) %}
2454                {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
2455            {%- else %}
2456                {{- '<|im_start|>' + message.role + '\n' + content }}
2457            {%- endif %}
2458        {%- else %}
2459            {{- '<|im_start|>' + message.role + '\n' + content }}
2460        {%- endif %}
2461        {%- if message.tool_calls %}
2462            {%- for tool_call in message.tool_calls %}
2463                {%- if (loop.first and content) or (not loop.first) %}
2464                    {{- '\n' }}
2465                {%- endif %}
2466                {%- if tool_call.function %}
2467                    {%- set tool_call = tool_call.function %}
2468                {%- endif %}
2469                {{- '<tool_call>\n{"name": "' }}
2470                {{- tool_call.name }}
2471                {{- '", "arguments": ' }}
2472                {%- if tool_call.arguments is string %}
2473                    {{- tool_call.arguments }}
2474                {%- else %}
2475                    {{- tool_call.arguments | tojson }}
2476                {%- endif %}
2477                {{- '}\n</tool_call>' }}
2478            {%- endfor %}
2479        {%- endif %}
2480        {{- '<|im_end|>\n' }}
2481    {%- elif message.role == "tool" %}
2482        {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
2483            {{- '<|im_start|>user' }}
2484        {%- endif %}
2485        {{- '\n<tool_response>\n' }}
2486        {{- content }}
2487        {{- '\n</tool_response>' }}
2488        {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
2489            {{- '<|im_end|>\n' }}
2490        {%- endif %}
2491    {%- endif %}
2492{%- endfor %}
2493{%- if add_generation_prompt %}
2494    {{- '<|im_start|>assistant\n<think>\n' }}
2495{%- endif %}"##;
2496
2497    fn qwen3_thinking_formatter() -> HfTokenizerConfigJsonFormatter {
2498        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2499            "chat_template": QWEN3_THINKING_TEMPLATE,
2500        }))
2501        .unwrap();
2502        HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2503    }
2504
2505    #[test]
2506    fn test_qwen3_thinking_template_flags_detected() {
2507        let formatter = qwen3_thinking_formatter();
2508        assert!(
2509            formatter.tool_use_template_handles_reasoning,
2510            "template references reasoning_content directly"
2511        );
2512        // The Qwen3-Thinking template is registered as both `default` and
2513        // `tool_use` (single-string HF chat template), so both flags must fire.
2514        assert!(
2515            formatter.default_template_handles_tool_calls_arguments_string,
2516            "default template branches on `arguments is string`"
2517        );
2518        assert!(
2519            formatter.tool_use_template_handles_tool_calls_arguments_string,
2520            "tool_use template branches on `arguments is string`"
2521        );
2522    }
2523
2524    /// Across a multi-step tool-use turn, the rendered prompt for turn N+1
2525    /// must be a strict prefix-extension of [turn-N prompt + bytes the model
2526    /// emitted on turn N]. Otherwise KV-cache prefix matching falls off a
2527    /// cliff every time a tool result comes back.
2528    ///
2529    /// The Qwen3-Thinking template's `is string` branch (template lines 63-67)
2530    /// renders `tool_call.arguments` verbatim from the OpenAI-canonical JSON
2531    /// string. Pre-parsing that string into an object forces the `else` branch
2532    /// and re-emits with minijinja's compact `tojson`, breaking append-only.
2533    #[test]
2534    fn test_qwen3_thinking_append_only_across_tool_use_turn() {
2535        let formatter = qwen3_thinking_formatter();
2536
2537        let tools = serde_json::json!([{
2538            "type": "function",
2539            "function": {
2540                "name": "get_weather",
2541                "description": "Get the current weather for a location",
2542                "parameters": {
2543                    "type": "object",
2544                    "properties": {
2545                        "location": {"type": "string"},
2546                        "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
2547                    },
2548                    "required": ["location"]
2549                }
2550            }
2551        }]);
2552
2553        // Turn 1: server is asked to produce the first assistant turn.
2554        let turn1_request: NvCreateChatCompletionRequest =
2555            serde_json::from_value(serde_json::json!({
2556                "model": "qwen3-thinking",
2557                "messages": [
2558                    {"role": "system", "content": "You are a helpful assistant."},
2559                    {"role": "user", "content": "What's the weather in San Francisco?"},
2560                ],
2561                "tools": tools,
2562            }))
2563            .unwrap();
2564        let p1 = formatter.render(&turn1_request).unwrap();
2565
2566        // Bytes the model emits next. Spacing matches the Qwen3 training
2567        // distribution (Python jinja2 / json.dumps defaults: `, ` and `: `).
2568        // Empty content + reasoning + a tool call.
2569        let model_emitted = "I'll call get_weather for SF.\n\
2570            </think>\n\n\
2571            <tool_call>\n\
2572            {\"name\": \"get_weather\", \"arguments\": {\"location\": \"San Francisco\", \"unit\": \"celsius\"}}\n\
2573            </tool_call><|im_end|>\n";
2574        let wire_after_t1 = format!("{p1}{model_emitted}");
2575
2576        // Turn 2: client sends the prior assistant turn back in OpenAI canonical
2577        // form (arguments as a JSON STRING with spaces) plus the tool result.
2578        let turn2_request: NvCreateChatCompletionRequest =
2579            serde_json::from_value(serde_json::json!({
2580                "model": "qwen3-thinking",
2581                "messages": [
2582                    {"role": "system", "content": "You are a helpful assistant."},
2583                    {"role": "user", "content": "What's the weather in San Francisco?"},
2584                    {
2585                        "role": "assistant",
2586                        "content": "",
2587                        "reasoning_content": "I'll call get_weather for SF.",
2588                        "tool_calls": [{
2589                            "id": "call_sf",
2590                            "type": "function",
2591                            "function": {
2592                                "name": "get_weather",
2593                                "arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
2594                            }
2595                        }]
2596                    },
2597                    {
2598                        "role": "tool",
2599                        "tool_call_id": "call_sf",
2600                        "content": "{\"temp\": 18, \"conditions\": \"Foggy\"}"
2601                    }
2602                ],
2603                "tools": tools,
2604            }))
2605            .unwrap();
2606        let p2 = formatter.render(&turn2_request).unwrap();
2607
2608        if !p2.starts_with(&wire_after_t1) {
2609            // Find first divergence and report it for easy debugging.
2610            let div = wire_after_t1
2611                .as_bytes()
2612                .iter()
2613                .zip(p2.as_bytes())
2614                .position(|(a, b)| a != b)
2615                .unwrap_or_else(|| wire_after_t1.len().min(p2.len()));
2616            let lo = div.saturating_sub(40);
2617            panic!(
2618                "turn-2 prompt is NOT a prefix-extension of [turn-1 + model bytes]\n  \
2619                 diverges at byte {div}\n  \
2620                 wire ends: ...{}|{}\n  \
2621                 t2 has:    ...{}|{}",
2622                String::from_utf8_lossy(&wire_after_t1.as_bytes()[lo..div]),
2623                String::from_utf8_lossy(
2624                    &wire_after_t1.as_bytes()[div..(div + 60).min(wire_after_t1.len())]
2625                ),
2626                String::from_utf8_lossy(&p2.as_bytes()[lo..div]),
2627                String::from_utf8_lossy(&p2.as_bytes()[div..(div + 60).min(p2.len())]),
2628            );
2629        }
2630
2631        // The only new bytes in P2 should be the tool response and the next
2632        // generation prompt — nothing in the prior conversation should change.
2633        let suffix = &p2[wire_after_t1.len()..];
2634        assert!(
2635            suffix.contains("<tool_response>"),
2636            "appended bytes must include the tool response, got: {suffix}"
2637        );
2638        assert!(
2639            suffix.ends_with("<|im_start|>assistant\n<think>\n"),
2640            "appended bytes must end with the next generation prompt, got: {suffix}"
2641        );
2642    }
2643}