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