Skip to main content

dynamo_renderer/template/
oai.rs

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