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