Skip to main content

dynamo_renderer/template/
oai.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use super::*;
5
6use crate::{OAIChatLikeRequest, TextInput};
7use minijinja::{context, value::Value};
8use serde_json::json;
9use std::result::Result::Ok;
10
11/// Fix a tool schema that is missing `type`/`properties`. `pub` so consumers
12/// can normalize their own `tools` value when implementing
13/// [`crate::OAIChatLikeRequest::tools`].
14pub fn may_be_fix_tool_schema(tools: serde_json::Value) -> Option<Value> {
15    // No need to validate or enforce other schema checks as the basic Named function schema is already validated while creating the request.
16    // Empty parameters is allowed by OpenAI at request level. Need to enforce it at template level.
17    // Whenever parameters is empty, insert "type": "object" and "properties": {}
18    let mut updated_tools = Vec::new();
19    if let Some(arr) = tools.as_array() {
20        for tool in arr {
21            let mut tool = tool.clone();
22            if let Some(function) = tool.get_mut("function") {
23                // Backfill a missing/null `description`. It's optional in the
24                // OpenAI tool schema, but some chat templates (e.g. gpt-oss
25                // harmony) concatenate it unconditionally and fail on an
26                // `undefined`/null value.
27                if let Some(obj) = function.as_object_mut()
28                    && !matches!(obj.get("description"), Some(serde_json::Value::String(_)))
29                {
30                    obj.insert(
31                        "description".to_string(),
32                        serde_json::Value::String(String::new()),
33                    );
34                }
35            }
36            if let Some(function) = tool.get_mut("function")
37                && let Some(parameters) = function.get_mut("parameters")
38            {
39                // Only operate if parameters is an object
40                if parameters.is_object() {
41                    let mut needs_type = false;
42                    let mut needs_properties = false;
43                    let is_empty = parameters
44                        .as_object()
45                        .map(|o| o.is_empty())
46                        .unwrap_or(false);
47
48                    // If empty, we need to insert both
49                    if is_empty {
50                        needs_type = true;
51                        needs_properties = true;
52                    } else {
53                        // If not empty, check if type/properties are missing
54                        if let Some(obj) = parameters.as_object() {
55                            if !obj.contains_key("type") {
56                                needs_type = true;
57                            }
58                            if !obj.contains_key("properties") {
59                                needs_properties = true;
60                            }
61                        }
62                    }
63
64                    if (needs_type || needs_properties)
65                        && let Some(obj) = parameters.as_object_mut()
66                    {
67                        if needs_type {
68                            obj.insert(
69                                "type".to_string(),
70                                serde_json::Value::String("object".to_string()),
71                            );
72                        }
73                        if needs_properties {
74                            obj.insert(
75                                "properties".to_string(),
76                                serde_json::Value::Object(Default::default()),
77                            );
78                        }
79                    }
80                }
81            }
82            updated_tools.push(tool);
83        }
84    }
85    Some(Value::from_serialize(&updated_tools))
86}
87
88/// Default media type conversions for multimodal content.
89/// Maps source types (e.g., "image_url") to target placeholder types (e.g., "image").
90const DEFAULT_MEDIA_TYPE_CONVERSIONS: &[(&str, &str)] = &[
91    ("image_url", "image"),
92    ("video_url", "video"),
93    ("audio_url", "audio"),
94];
95
96/// Convert media URL content parts to empty placeholder types.
97fn convert_media_url_to_placeholder(
98    content_array: &mut [serde_json::Value],
99    conversions: &[(&str, &str)],
100) {
101    for part in content_array {
102        let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
103        if let Some((_, target_type)) = conversions.iter().find(|(src, _)| *src == part_type) {
104            *part = serde_json::json!({"type": target_type});
105        }
106    }
107}
108
109fn may_be_fix_msg_content(
110    mut messages: serde_json::Value,
111    preserve_arrays: bool,
112    image_placeholder_template: Option<&str>,
113) -> serde_json::Value {
114    let Some(arr) = messages.as_array_mut() else {
115        return messages;
116    };
117    for msg in arr {
118        let Some(content) = msg.get_mut("content") else {
119            continue;
120        };
121        match content {
122            serde_json::Value::String(_) if preserve_arrays => {
123                let text = content.take();
124                *content = serde_json::Value::Array(vec![serde_json::Value::Object(
125                    serde_json::Map::from_iter([
126                        ("type".into(), serde_json::Value::String("text".into())),
127                        ("text".into(), text),
128                    ]),
129                )]);
130            }
131            serde_json::Value::Array(parts) => {
132                convert_media_url_to_placeholder(parts, DEFAULT_MEDIA_TYPE_CONVERSIONS);
133                // An empty array must remain an array. Templates distinguish it
134                // from an empty string, including when the placeholder is "".
135                let text_only = !parts.is_empty()
136                    && parts
137                        .iter()
138                        .all(|part| part.get("type").and_then(|v| v.as_str()) == Some("text"));
139                if text_only && !preserve_arrays {
140                    let text = parts
141                        .iter()
142                        .filter_map(|part| part.get("text")?.as_str())
143                        .collect::<Vec<_>>()
144                        .join("\n");
145                    *content = serde_json::Value::String(text);
146                } else if !preserve_arrays
147                    && !parts.is_empty()
148                    && let Some(template) = image_placeholder_template
149                {
150                    *content = serde_json::Value::String(flatten_mixed_content(parts, template));
151                }
152            }
153            _ => {}
154        }
155    }
156    messages
157}
158
159/// Concatenate a mixed-content array (text parts + image placeholders) into a
160/// single string. Text parts contribute their `text` field as-is; non-text
161/// parts (image, video, audio after the URL→placeholder conversion in
162/// `convert_media_url_to_placeholder`) emit the per-family placeholder with
163/// `{n}` substituted by the 1-based index of the image in the message.
164///
165/// Used in `may_be_fix_msg_content` when `preserve_arrays=false` and the
166/// template knows a placeholder convention — currently Phi-3-vision
167/// (`<|image_{n}|>`), LLaVA-1.5 (`<image>`), and pure pass-through /
168/// encoder-decoder templates (`""`, image emits nothing — Nemotron-Parse).
169/// With an empty `placeholder_tpl` the non-text parts contribute no characters,
170/// so the result is the concatenated text parts only.
171///
172/// **Caveat — non-text index slot:** `img_idx` increments for every non-text
173/// part, not just images. The current supported families (Phi-3, LLaVA-1.5)
174/// are image-only so there's no collision today, but a future image+video
175/// family would silently consume an image-index slot for each video/audio
176/// part and emit the image placeholder there. When adding a family that
177/// mixes modalities in one message, either:
178///   1. expand this function with per-modality placeholder strings, or
179///   2. assert in `convert_media_url_to_placeholder` that only "image"
180///      placeholders reach this path.
181fn flatten_mixed_content(parts: &[serde_json::Value], placeholder_tpl: &str) -> String {
182    let mut out = String::new();
183    let mut img_idx: u32 = 1;
184    for part in parts {
185        let type_str = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
186        if type_str == "text" {
187            if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
188                out.push_str(text);
189            }
190        } else if !type_str.is_empty() {
191            let placeholder = placeholder_tpl.replace("{n}", &img_idx.to_string());
192            out.push_str(&placeholder);
193            img_idx += 1;
194        }
195    }
196    out
197}
198
199fn normalize_tool_calls_arguments_in_messages(messages: &mut serde_json::Value) {
200    // Deserialize `tool_calls[].function.arguments` from JSON strings to
201    // objects/arrays before template rendering — avoids double encoding
202    // and enables iteration. Skipped for templates whose own `is string`
203    // branch wants the raw string verbatim (see render()).
204    let Some(msgs) = messages.as_array_mut() else {
205        return;
206    };
207
208    for msg in msgs.iter_mut() {
209        if let Some(tool_calls) = msg.get_mut("tool_calls").and_then(|v| v.as_array_mut()) {
210            for tc in tool_calls {
211                if let Some(function) = tc.get_mut("function").and_then(|v| v.as_object_mut())
212                    && let Some(args) = function.get_mut("arguments")
213                    && let Some(s) = args.as_str()
214                    && let Ok(parsed) = serde_json::from_str(s)
215                {
216                    *args = parsed;
217                }
218            }
219        }
220    }
221}
222
223fn normalize_function_call_arguments_in_messages(messages: &mut serde_json::Value) {
224    // Legacy (deprecated) OpenAI `function_call.arguments` path. Kept separate
225    // from `tool_calls` normalization so the per-template `arguments is string`
226    // opt-out — which only refers to `tool_call.arguments` inside the
227    // tool_calls loop — does not accidentally suppress this path.
228    let Some(msgs) = messages.as_array_mut() else {
229        return;
230    };
231
232    for msg in msgs.iter_mut() {
233        if let Some(function_call) = msg.get_mut("function_call").and_then(|v| v.as_object_mut())
234            && let Some(args) = function_call.get_mut("arguments")
235            && let Some(s) = args.as_str()
236            && let Ok(parsed) = serde_json::from_str(s)
237        {
238            *args = parsed;
239        }
240    }
241}
242
243/// Inject `reasoning_content` back into the `content` field as `<think>` blocks.
244///
245/// Chat templates only reference `{{ message.content }}` — they don't know about
246/// `reasoning_content`. Without this injection, the model's prior chain-of-thought
247/// is silently dropped across turns.
248///
249/// Uses `<think>`/`</think>` delimiters — the same tags that reasoning models emit
250/// and that the reasoning parser strips on output. Reasoning is prepended to content
251/// to match the original generation order (`<think>...</think> response`).
252///
253/// Segments are concatenated rather than interleaved with tool_calls because Jinja
254/// templates render `tool_calls` separately from `content`. The model still sees
255/// all reasoning text before the template-rendered tool call block.
256fn inject_reasoning_content_into_messages(messages: &mut serde_json::Value) {
257    let Some(msgs) = messages.as_array_mut() else {
258        return;
259    };
260
261    for msg in msgs.iter_mut() {
262        if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") {
263            continue;
264        }
265
266        let reasoning = match msg.get("reasoning_content") {
267            Some(serde_json::Value::String(s)) if !s.is_empty() => {
268                format!("<think>{}</think>", s)
269            }
270            Some(serde_json::Value::Array(segments)) => {
271                let mut result = String::new();
272                for seg in segments {
273                    if let Some(s) = seg.as_str()
274                        && !s.is_empty()
275                    {
276                        result.push_str("<think>");
277                        result.push_str(s);
278                        result.push_str("</think>");
279                    }
280                }
281                if result.is_empty() {
282                    continue;
283                }
284                result
285            }
286            _ => continue,
287        };
288
289        match msg.get("content") {
290            // Content is a string or null — prepend reasoning as text
291            Some(serde_json::Value::String(s)) if !s.is_empty() => {
292                msg["content"] = serde_json::Value::String(format!("{}{}", reasoning, s));
293            }
294            None | Some(serde_json::Value::Null) | Some(serde_json::Value::String(_)) => {
295                msg["content"] = serde_json::Value::String(reasoning);
296            }
297            // Content is an array (multimodal) — prepend as a text part
298            Some(serde_json::Value::Array(_)) => {
299                let think_part = serde_json::json!({
300                    "type": "text",
301                    "text": reasoning
302                });
303                if let Some(arr) = msg.get_mut("content").and_then(|v| v.as_array_mut()) {
304                    arr.insert(0, think_part);
305                }
306            }
307            // Other types (number, bool, object) — skip, don't corrupt
308            _ => continue,
309        }
310
311        // Remove so the template doesn't see both the injected <think> in content
312        // and the original reasoning_content field.
313        if let Some(obj) = msg.as_object_mut() {
314            obj.remove("reasoning_content");
315        }
316    }
317}
318
319/// Default [`OAIChatLikeRequest`] impl for the bare `dynamo-protocols` chat
320/// request. Lets any consumer (e.g. a standalone OpenAI frontend over an
321/// engine) render HF chat templates directly from the wire type, without
322/// defining their own wrapper. Consumers with extra fields (Dynamo's
323/// `NvCreateChatCompletionRequest`) provide their own impl.
324impl OAIChatLikeRequest for dynamo_protocols::types::CreateChatCompletionRequest {
325    fn model(&self) -> String {
326        self.model.clone()
327    }
328
329    fn messages(&self) -> Value {
330        let messages_json = serde_json::to_value(&self.messages).unwrap();
331        Value::from_serialize(&messages_json)
332    }
333
334    fn typed_messages(&self) -> Option<&[dynamo_protocols::types::ChatCompletionRequestMessage]> {
335        Some(self.messages.as_slice())
336    }
337
338    fn tools(&self) -> Option<Value> {
339        if self.tools.is_none() {
340            None
341        } else {
342            Some(may_be_fix_tool_schema(
343                serde_json::to_value(&self.tools).unwrap(),
344            )?)
345        }
346    }
347
348    fn tool_choice(&self) -> Option<Value> {
349        if self.tool_choice.is_none() {
350            None
351        } else {
352            Some(Value::from_serialize(&self.tool_choice))
353        }
354    }
355
356    fn response_format(&self) -> Option<Value> {
357        self.response_format.as_ref().map(Value::from_serialize)
358    }
359
360    fn reasoning_effort(&self) -> Option<Value> {
361        self.reasoning_effort.as_ref().map(Value::from_serialize)
362    }
363
364    fn should_add_generation_prompt(&self) -> bool {
365        // Using vLLM default behavior
366        true
367    }
368
369    fn extract_text(&self) -> Option<TextInput> {
370        Some(TextInput::Single(String::new()))
371    }
372
373    fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
374        self.mm_processor_kwargs.as_ref()
375    }
376}
377
378/// Joins two message contents without flattening to text: a part array is
379/// spliced part for part, so image parts survive a merge. A string target is
380/// promoted to an array when the source carries parts.
381fn merge_message_content(
382    target: serde_json::Value,
383    source: serde_json::Value,
384) -> serde_json::Value {
385    use serde_json::Value;
386    let text_part = |text: String| json!({"type": "text", "text": text});
387    match (target, source) {
388        (Value::String(mut target), Value::String(source)) => {
389            if !target.is_empty() && !source.is_empty() {
390                target.push_str("\n\n");
391            }
392            target.push_str(&source);
393            Value::String(target)
394        }
395        (Value::Array(mut target), Value::Array(source)) => {
396            target.extend(source);
397            Value::Array(target)
398        }
399        (Value::Array(mut target), Value::String(source)) => {
400            if !source.is_empty() {
401                target.push(text_part(source));
402            }
403            Value::Array(target)
404        }
405        (Value::String(target), Value::Array(source)) => {
406            let mut parts = Vec::with_capacity(source.len() + 1);
407            if !target.is_empty() {
408                parts.push(text_part(target));
409            }
410            parts.extend(source);
411            Value::Array(parts)
412        }
413        (Value::Null, source) => source,
414        // Content is a string or a part array in every shape the schema allows.
415        (target, _) => target,
416    }
417}
418
419/// Merges `source` into `target`, leaving every other field on `target` intact.
420fn append_message_content(target: &mut serde_json::Value, source: serde_json::Value) {
421    let Some(target) = target.as_object_mut() else {
422        return;
423    };
424    let merged = merge_message_content(
425        target.remove("content").unwrap_or(serde_json::Value::Null),
426        source,
427    );
428    target.insert("content".to_string(), merged);
429}
430
431fn take_message_content(message: &mut serde_json::Value) -> serde_json::Value {
432    message
433        .get_mut("content")
434        .map(serde_json::Value::take)
435        .unwrap_or(serde_json::Value::Null)
436}
437
438/// Rewrites an agent-client message stream into a shape strict chat templates
439/// accept. Each rewrite is gated on the restriction the load-time probe actually
440/// found, so a template is never reshaped for a rule it does not enforce.
441fn normalize_system_messages(messages: &mut serde_json::Value, rules: SystemNormalization) {
442    let serde_json::Value::Array(list) = messages else {
443        return;
444    };
445    let role_is =
446        |m: &serde_json::Value, r: &str| m.get("role").and_then(|v| v.as_str()) == Some(r);
447
448    if rules.demote_nonleading_system {
449        // A template that rejects a system turn away from index 0 rejects a
450        // leading run of them too, so the run collapses into the first.
451        let leading = list.iter().take_while(|m| role_is(m, "system")).count();
452        if leading > 1 {
453            for mut trailing in list.drain(1..leading).collect::<Vec<_>>() {
454                let content = take_message_content(&mut trailing);
455                append_message_content(&mut list[0], content);
456            }
457        }
458
459        // Demoted in place, not folded to the front: a mid-conversation reminder
460        // that toggles would otherwise invalidate the whole KV prefix each turn.
461        let leading = list.iter().take_while(|m| role_is(m, "system")).count();
462        for m in list.iter_mut().skip(leading) {
463            if role_is(m, "system")
464                && let Some(m) = m.as_object_mut()
465            {
466                m.insert("role".to_string(), json!("user"));
467            }
468        }
469    }
470
471    if rules.coalesce_consecutive_users {
472        let mut coalesced: Vec<serde_json::Value> = Vec::with_capacity(list.len());
473        for mut m in list.drain(..) {
474            if role_is(&m, "user") && coalesced.last().is_some_and(|p| role_is(p, "user")) {
475                let content = take_message_content(&mut m);
476                append_message_content(coalesced.last_mut().unwrap(), content);
477            } else {
478                coalesced.push(m);
479            }
480        }
481        *list = coalesced;
482    }
483}
484
485impl OAIPromptFormatter for HfTokenizerConfigJsonFormatter {
486    fn supports_add_generation_prompt(&self) -> bool {
487        self.supports_add_generation_prompt
488    }
489
490    fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
491        let mixins = Value::from_dyn_object(self.mixins.clone());
492
493        let tools = req.tools();
494        // Strip tools when tool_choice is "none" and the flag is enabled, so the model
495        // doesn't see tool definitions and generate raw XML tool calls in its response.
496        let tools = if self.exclude_tools_when_tool_choice_none {
497            match req.tool_choice() {
498                Some(ref tc) if tc.as_str() == Some("none") => None,
499                _ => tools,
500            }
501        } else {
502            tools
503        };
504        // has_tools should be true if tools is a non-empty array
505        let has_tools = tools.as_ref().and_then(|v| v.len()).is_some_and(|l| l > 0);
506        let add_generation_prompt = req.should_add_generation_prompt();
507
508        tracing::trace!(
509            "Rendering prompt with tools: {:?}, add_generation_prompt: {}",
510            has_tools,
511            add_generation_prompt
512        );
513
514        // Pick the concrete template before applying any template-specific
515        // message rewrites or field normalization.
516        let (
517            template_name,
518            template_handles_tool_calls_args_string,
519            template_handles_reasoning,
520            system_normalization,
521        ) = if has_tools {
522            (
523                "tool_use",
524                self.tool_use_template_handles_tool_calls_arguments_string,
525                self.tool_use_template_handles_reasoning,
526                self.tool_use_system_normalization,
527            )
528        } else {
529            (
530                "default",
531                self.default_template_handles_tool_calls_arguments_string,
532                self.default_template_handles_reasoning,
533                self.default_system_normalization,
534            )
535        };
536
537        let mut messages_for_template = crate::messages_to_json(req)?;
538
539        crate::reject_unsupported_partial_assistant(&messages_for_template)?;
540        crate::reject_unsupported_message_tools(&messages_for_template, &[])?;
541
542        if system_normalization.is_required() {
543            normalize_system_messages(&mut messages_for_template, system_normalization);
544        }
545
546        messages_for_template = may_be_fix_msg_content(
547            messages_for_template,
548            self.requires_content_arrays,
549            self.image_placeholder_template,
550        );
551
552        // Pre-parse JSON-string `arguments` into objects — but only for templates
553        // that unconditionally `| tojson` them. Templates that branch on
554        // `tool_call.arguments is string` (Qwen3, Hermes) want the raw string
555        // verbatim so the rendered bytes match what the model emitted on the
556        // prior turn. Re-serializing through minijinja's compact `tojson` here
557        // breaks append-only prefix matching across multi-step tool use.
558        if !template_handles_tool_calls_args_string {
559            normalize_tool_calls_arguments_in_messages(&mut messages_for_template);
560        }
561        // Legacy `function_call.arguments` is always normalized — the
562        // `arguments is string` opt-out only covers the modern `tool_calls`
563        // branch.
564        normalize_function_call_arguments_in_messages(&mut messages_for_template);
565
566        // Inject reasoning_content as <think> blocks into content — but only if
567        // the template doesn't handle it natively. Templates like Nemotron and
568        // Qwen3 reference reasoning_content directly in their Jinja logic; injecting
569        // would produce duplicate <think> blocks.
570        if !template_handles_reasoning {
571            inject_reasoning_content_into_messages(&mut messages_for_template);
572        }
573
574        let ctx = context! {
575            messages => messages_for_template,
576            tools => tools,
577            bos_token => self.config.bos_tok(),
578            eos_token => self.config.eos_tok(),
579            unk_token => self.config.unk_tok(),
580            add_generation_prompt => add_generation_prompt,
581            ..mixins
582        };
583
584        // Merge any additional args into the context last so they take precedence
585        let ctx = if let Some(args) = req.chat_template_args() {
586            let extra = Value::from_serialize(args);
587            context! { ..ctx, ..extra }
588        } else {
589            ctx
590        };
591
592        let tmpl: minijinja::Template<'_, '_> = self.env.get_template(template_name)?;
593        Ok(tmpl.render(&ctx)?)
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    use dynamo_protocols::types::ChatCompletionRequestMessage as Msg;
602    // The crate's renderer tests exercise the bare-protocol request type via the
603    // default `OAIChatLikeRequest` impl above; Dynamo's `Nv*` wrapper lives in lib/llm.
604    use dynamo_protocols::types::CreateChatCompletionRequest as NvCreateChatCompletionRequest;
605    use minijinja::{Environment, context};
606
607    // --- adaptive system-message normalization (#11762) --------------------
608
609    use super::super::tokcfg::ChatTemplate as SysChatTemplate;
610    use super::super::{
611        ContextMixins as SysMixins, HfTokenizerConfigJsonFormatter as SysFormatter,
612    };
613
614    fn formatter_for(template: &str) -> SysFormatter {
615        // Dummy tokens: real templates that append eos/bos won't render without them.
616        let ct: SysChatTemplate = serde_json::from_value(json!({
617            "chat_template": template,
618            "bos_token": "<s>",
619            "eos_token": "</s>",
620            "unk_token": "<unk>",
621        }))
622        .unwrap();
623        SysFormatter::new(ct, SysMixins::new(&[])).unwrap()
624    }
625
626    fn formatter_for_templates(default: &str, tool_use: &str) -> SysFormatter {
627        let ct: SysChatTemplate = serde_json::from_value(json!({
628            "chat_template": [
629                {"default": default},
630                {"tool_use": tool_use},
631            ],
632            "bos_token": "<s>",
633            "eos_token": "</s>",
634            "unk_token": "<unk>",
635        }))
636        .unwrap();
637        SysFormatter::new(ct, SysMixins::new(&[])).unwrap()
638    }
639
640    fn try_formatter_for(template: &str) -> Option<SysFormatter> {
641        let ct: SysChatTemplate = serde_json::from_value(json!({
642            "chat_template": template,
643            "bos_token": "<s>",
644            "eos_token": "</s>",
645            "unk_token": "<unk>",
646        }))
647        .ok()?;
648        SysFormatter::new(ct, SysMixins::new(&[])).ok()
649    }
650
651    fn render_shape(f: &SysFormatter, messages: serde_json::Value) -> Result<String> {
652        let req: NvCreateChatCompletionRequest =
653            serde_json::from_value(json!({ "model": "test", "messages": messages })).unwrap();
654        f.render(&req)
655    }
656
657    fn render_shape_with_tools(f: &SysFormatter, messages: serde_json::Value) -> Result<String> {
658        let req: NvCreateChatCompletionRequest = serde_json::from_value(json!({
659            "model": "test",
660            "messages": messages,
661            "tools": [{
662                "type": "function",
663                "function": {"name": "noop", "parameters": {}}
664            }]
665        }))
666        .unwrap();
667        f.render(&req)
668    }
669
670    struct RawMessagesRequest(Value);
671
672    impl OAIChatLikeRequest for RawMessagesRequest {
673        fn model(&self) -> String {
674            "test".to_string()
675        }
676
677        fn messages(&self) -> Value {
678            self.0.clone()
679        }
680
681        fn should_add_generation_prompt(&self) -> bool {
682            true
683        }
684    }
685
686    fn render_raw_shape(f: &SysFormatter, messages: serde_json::Value) -> Result<String> {
687        f.render(&RawMessagesRequest(Value::from_serialize(&messages)))
688    }
689
690    #[test]
691    fn content_normalization_preserves_unchanged_values() {
692        let messages = json!([
693            {"role": "user", "content": "  中文 <special>\n", "name": "user"},
694            {"role": "assistant", "content": null},
695            {"role": "user", "content": []},
696            {"role": "assistant", "tool_calls": []}
697        ]);
698        assert_eq!(
699            may_be_fix_msg_content(messages.clone(), false, Some("")),
700            messages
701        );
702    }
703
704    const PERMISSIVE_TMPL: &str = concat!(
705        "{%- for m in messages -%}",
706        "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
707        "{%- endfor -%}"
708    );
709
710    #[test]
711    fn jinja_templates_reject_message_level_tools() {
712        let f = formatter_for(PERMISSIVE_TMPL);
713        let error = render_shape(
714            &f,
715            json!([
716                {"role": "system", "tools": [{"name": "lookup", "parameters": {"type": "object"}}]},
717                {"role": "user", "content": "hi"}
718            ]),
719        )
720        .unwrap_err();
721        assert!(matches!(
722            error.downcast_ref::<crate::PromptRenderError>(),
723            Some(crate::PromptRenderError::InvalidRequest(message))
724                if message.contains("message-level `tools`")
725        ));
726
727        let error = render_raw_shape(
728            &f,
729            json!([{
730                "role": "user",
731                "content": "hi",
732                "tools": [{"name": "lookup", "parameters": {"type": "object"}}]
733            }]),
734        )
735        .unwrap_err();
736        assert!(matches!(
737            error.downcast_ref::<crate::PromptRenderError>(),
738            Some(crate::PromptRenderError::InvalidRequest(message))
739                if message.contains("message-level `tools`")
740        ));
741
742        let rendered = render_shape(
743            &f,
744            json!([
745                {"role": "system", "content": "You are helpful.", "tools": []},
746                {"role": "user", "content": "hi"}
747            ]),
748        )
749        .unwrap();
750        assert!(rendered.contains("<|im_start|>system\nYou are helpful.<|im_end|>"));
751    }
752
753    #[test]
754    fn jinja_templates_reject_unsupported_partial_assistant() {
755        let f = formatter_for(PERMISSIVE_TMPL);
756        let error = render_shape(
757            &f,
758            json!([
759                {"role": "user", "content": "Continue"},
760                {"role": "assistant", "content": "prefix", "partial": true}
761            ]),
762        )
763        .unwrap_err();
764        assert!(matches!(
765            error.downcast_ref::<crate::PromptRenderError>(),
766            Some(crate::PromptRenderError::InvalidRequest(message))
767                if message.contains("`partial: true` is not supported")
768        ));
769
770        let rendered = render_shape(
771            &f,
772            json!([{"role": "assistant", "content": "ordinary", "partial": false}]),
773        )
774        .unwrap();
775        assert!(rendered.contains("ordinary"));
776    }
777    // Rejects a non-leading system (Qwen3.5 shape); accepts consecutive users.
778    const STRICT_LEADING_TMPL: &str = concat!(
779        "{%- for m in messages -%}",
780        "{%- if m.role == 'system' and not loop.first -%}",
781        "{{ raise_exception('System message must be at the beginning.') }}",
782        "{%- endif -%}",
783        "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
784        "{%- endfor -%}"
785    );
786    // Rejects consecutive user turns only; accepts a non-leading system.
787    const ALTERNATION_TMPL: &str = concat!(
788        "{%- set ns = namespace(prev='') -%}",
789        "{%- for m in messages -%}",
790        "{%- if m.role == 'user' and ns.prev == 'user' -%}",
791        "{{ raise_exception('Conversation roles must alternate.') }}",
792        "{%- endif -%}",
793        "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
794        "{%- set ns.prev = m.role -%}",
795        "{%- endfor -%}"
796    );
797    // Rejects both restrictions (Gemma-3 / Mistral shape).
798    const STRICT_BOTH_TMPL: &str = concat!(
799        "{%- set ns = namespace(prev='') -%}",
800        "{%- for m in messages -%}",
801        "{%- if m.role == 'system' and not loop.first -%}",
802        "{{ raise_exception('System message must be at the beginning.') }}",
803        "{%- endif -%}",
804        "{%- if m.role == 'user' and ns.prev == 'user' -%}",
805        "{{ raise_exception('Conversation roles must alternate.') }}",
806        "{%- endif -%}",
807        "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
808        "{%- set ns.prev = m.role -%}",
809        "{%- endfor -%}"
810    );
811    // Mirrors templates that only enforce role constraints when a particular
812    // tools shape is present.
813    const DEFAULT_NONE_GATED_TMPL: &str = concat!(
814        "{%- set strict = tools is not none -%}",
815        "{%- for m in messages -%}",
816        "{%- if strict and m.role == 'system' and not loop.first -%}",
817        "{{ raise_exception('System message must be at the beginning.') }}",
818        "{%- endif -%}",
819        "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
820        "{%- endfor -%}"
821    );
822    const TOOL_NONEMPTY_GATED_TMPL: &str = concat!(
823        "{%- set strict = tools|length > 0 -%}",
824        "{%- for m in messages -%}",
825        "{%- if strict and m.role == 'system' and not loop.first -%}",
826        "{{ raise_exception('System message must be at the beginning.') }}",
827        "{%- endif -%}",
828        "<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n",
829        "{%- endfor -%}"
830    );
831    // Ignores string content and only renders text parts from content arrays.
832    const STRICT_ARRAY_TMPL: &str = concat!(
833        "{%- for m in messages -%}",
834        "{%- if m.role == 'system' and not loop.first -%}",
835        "{{ raise_exception('System message must be at the beginning.') }}",
836        "{%- endif -%}",
837        "<|im_start|>{{ m.role }}\n",
838        "{%- if m.content is not string -%}",
839        "{%- for part in m.content -%}{{ part.text }}{%- endfor -%}",
840        "{%- endif -%}",
841        "<|im_end|>\n",
842        "{%- endfor -%}"
843    );
844
845    // Claude Code first-turn shape: top-level system + a mid-array system.
846    fn claude_shape() -> serde_json::Value {
847        json!([
848            {"role": "system", "content": "You are Claude Code."},
849            {"role": "user", "content": "hello"},
850            {"role": "system", "content": "mid-conversation reminder"},
851        ])
852    }
853
854    fn all_restrictions() -> SystemNormalization {
855        SystemNormalization {
856            demote_nonleading_system: true,
857            coalesce_consecutive_users: true,
858        }
859    }
860
861    #[test]
862    fn permissive_template_is_not_flagged_and_renders_untouched() {
863        let f = formatter_for(PERMISSIVE_TMPL);
864        assert!(!f.default_system_normalization.is_required());
865        assert!(!f.tool_use_system_normalization.is_required());
866        let out = render_shape(&f, claude_shape()).unwrap();
867        assert!(out.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
868    }
869
870    #[test]
871    fn strict_leading_template_demotes_mid_system_but_keeps_user_turns_apart() {
872        let f = formatter_for(STRICT_LEADING_TMPL);
873        assert!(f.default_system_normalization.demote_nonleading_system);
874        // The template accepts consecutive users, so demotion must not merge them.
875        assert!(!f.default_system_normalization.coalesce_consecutive_users);
876
877        // This shape returned a 500 before the probe existed.
878        let out = render_shape(&f, claude_shape()).unwrap();
879        assert_eq!(out.matches("<|im_start|>system").count(), 1);
880        assert!(out.contains("<|im_start|>user\nhello<|im_end|>"));
881        assert!(out.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
882    }
883
884    #[test]
885    fn alternation_template_coalesces_users_but_keeps_mid_system() {
886        let f = formatter_for(ALTERNATION_TMPL);
887        assert!(f.default_system_normalization.coalesce_consecutive_users);
888        // The template accepts a non-leading system, so it stays a system turn.
889        assert!(!f.default_system_normalization.demote_nonleading_system);
890
891        let out = render_shape(&f, claude_shape()).unwrap();
892        assert!(out.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
893
894        let out = render_shape(
895            &f,
896            json!([
897                {"role": "system", "content": "s"},
898                {"role": "user", "content": "hello"},
899                {"role": "user", "content": "again"},
900            ]),
901        )
902        .unwrap();
903        assert_eq!(out.matches("<|im_start|>user").count(), 1);
904        assert!(out.contains("<|im_start|>user\nhello\n\nagain<|im_end|>"));
905    }
906
907    #[test]
908    fn strict_both_template_demotes_then_coalesces() {
909        let f = formatter_for(STRICT_BOTH_TMPL);
910        assert!(f.default_system_normalization.demote_nonleading_system);
911        assert!(f.default_system_normalization.coalesce_consecutive_users);
912
913        let out = render_shape(&f, claude_shape()).unwrap();
914        assert_eq!(out.matches("<|im_start|>system").count(), 1);
915        assert!(out.contains("<|im_start|>user\nhello\n\nmid-conversation reminder<|im_end|>"));
916    }
917
918    #[test]
919    fn system_normalization_flag_is_selected_per_template() {
920        let f = formatter_for_templates(PERMISSIVE_TMPL, STRICT_LEADING_TMPL);
921        assert!(!f.default_system_normalization.is_required());
922        assert!(f.tool_use_system_normalization.is_required());
923
924        let no_tools = render_shape(&f, claude_shape()).unwrap();
925        assert!(no_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
926        let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
927        assert_eq!(with_tools.matches("<|im_start|>system").count(), 1);
928        assert!(with_tools.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
929
930        let f = formatter_for_templates(STRICT_LEADING_TMPL, PERMISSIVE_TMPL);
931        assert!(f.default_system_normalization.is_required());
932        assert!(!f.tool_use_system_normalization.is_required());
933        let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
934        assert!(with_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
935    }
936
937    #[test]
938    fn system_normalization_probe_uses_runtime_tools_shape() {
939        let f = formatter_for_templates(DEFAULT_NONE_GATED_TMPL, TOOL_NONEMPTY_GATED_TMPL);
940        assert!(!f.default_system_normalization.is_required());
941        assert!(f.tool_use_system_normalization.is_required());
942
943        let no_tools = render_shape(&f, claude_shape()).unwrap();
944        assert!(no_tools.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
945
946        let with_tools = render_shape_with_tools(&f, claude_shape()).unwrap();
947        assert_eq!(with_tools.matches("<|im_start|>system").count(), 1);
948        assert!(with_tools.contains("<|im_start|>user\nmid-conversation reminder<|im_end|>"));
949    }
950
951    #[test]
952    fn system_normalization_precedes_required_content_array_conversion() {
953        let f = formatter_for(STRICT_ARRAY_TMPL);
954        assert!(f.requires_content_arrays);
955        assert!(f.default_system_normalization.demote_nonleading_system);
956
957        let out = render_shape(
958            &f,
959            json!([
960                {"role": "system", "content": "A"},
961                {"role": "system", "content": "B"},
962                {"role": "user", "content": "hello"},
963            ]),
964        )
965        .unwrap();
966        assert!(out.contains("A\n\nB"));
967    }
968
969    #[test]
970    fn normalize_preserves_multimodal_user_content_and_fields() {
971        let mut m = json!([
972            {
973                "role": "user",
974                "name": "kept",
975                "content": [
976                    {"type": "text", "text": "look"},
977                    {"type": "image"},
978                ],
979            },
980            {"role": "system", "content": "remember"},
981        ]);
982        normalize_system_messages(&mut m, all_restrictions());
983        assert_eq!(
984            m,
985            json!([{
986                "role": "user",
987                "name": "kept",
988                "content": [
989                    {"type": "text", "text": "look"},
990                    {"type": "image"},
991                    {"type": "text", "text": "remember"},
992                ],
993            }])
994        );
995    }
996
997    /// The mirror of the case above: the parts belong to the turn being merged
998    /// away, not the one being merged into.
999    #[test]
1000    fn coalesce_preserves_multimodal_content_of_the_merged_turn() {
1001        let mut m = json!([
1002            {"role": "user", "content": "look"},
1003            {"role": "user", "content": [
1004                {"type": "text", "text": "at this"},
1005                {"type": "image_url", "image_url": {"url": "http://img"}},
1006            ]},
1007        ]);
1008        normalize_system_messages(&mut m, all_restrictions());
1009        assert_eq!(
1010            m,
1011            json!([{
1012                "role": "user",
1013                "content": [
1014                    {"type": "text", "text": "look"},
1015                    {"type": "text", "text": "at this"},
1016                    {"type": "image_url", "image_url": {"url": "http://img"}},
1017                ],
1018            }])
1019        );
1020    }
1021
1022    #[test]
1023    fn normalize_merges_leading_run_and_coalesces() {
1024        let mut m = json!([
1025            {"role": "system", "content": "A"},
1026            {"role": "system", "content": "B"},
1027            {"role": "user", "content": "hi"},
1028            {"role": "system", "content": "reminder"},
1029        ]);
1030        normalize_system_messages(&mut m, all_restrictions());
1031        assert_eq!(
1032            m,
1033            json!([
1034                {"role": "system", "content": "A\n\nB"},
1035                {"role": "user", "content": "hi\n\nreminder"},
1036            ])
1037        );
1038    }
1039
1040    /// Each restriction drives only its own rewrite, so a template is never
1041    /// reshaped for a rule it does not enforce.
1042    #[test]
1043    fn each_restriction_applies_only_its_own_rewrite() {
1044        let shape = json!([
1045            {"role": "system", "content": "A"},
1046            {"role": "system", "content": "B"},
1047            {"role": "user", "content": "hi"},
1048            {"role": "system", "content": "reminder"},
1049        ]);
1050
1051        let mut demote_only = shape.clone();
1052        normalize_system_messages(
1053            &mut demote_only,
1054            SystemNormalization {
1055                demote_nonleading_system: true,
1056                coalesce_consecutive_users: false,
1057            },
1058        );
1059        assert_eq!(
1060            demote_only,
1061            json!([
1062                {"role": "system", "content": "A\n\nB"},
1063                {"role": "user", "content": "hi"},
1064                {"role": "user", "content": "reminder"},
1065            ])
1066        );
1067
1068        let mut coalesce_only = shape.clone();
1069        normalize_system_messages(
1070            &mut coalesce_only,
1071            SystemNormalization {
1072                demote_nonleading_system: false,
1073                coalesce_consecutive_users: true,
1074            },
1075        );
1076        assert_eq!(coalesce_only, shape);
1077    }
1078
1079    #[test]
1080    fn normalize_preserves_array_system_content() {
1081        let mut m = json!([
1082            {"role": "user", "content": "hi"},
1083            {"role": "system", "content": [{"type": "text", "text": "one"},
1084                                           {"type": "text", "text": "two"}]},
1085        ]);
1086        normalize_system_messages(&mut m, all_restrictions());
1087        assert_eq!(
1088            m,
1089            json!([{"role": "user", "content": [
1090                {"type": "text", "text": "hi"},
1091                {"type": "text", "text": "one"},
1092                {"type": "text", "text": "two"},
1093            ]}])
1094        );
1095    }
1096
1097    /// Renders every agent-client shape through every template in a corpus of
1098    /// real models. A failure means the probe missed a strict template, or the
1099    /// normalization it triggered was not enough to satisfy one.
1100    ///
1101    /// TEMPLATE_CORPUS points at a dir of `<name>.jinja` files + `manifest.json`
1102    /// mapping each to `{model}` (see experiments/system-probe):
1103    ///   TEMPLATE_CORPUS=... cargo test -p dynamo-renderer \
1104    ///     adaptive_system_corpus_audit -- --ignored --nocapture
1105    #[test]
1106    #[ignore]
1107    fn adaptive_system_corpus_audit() {
1108        let dir =
1109            std::env::var("TEMPLATE_CORPUS").expect("set TEMPLATE_CORPUS to the templates dir");
1110        let manifest: serde_json::Value =
1111            serde_json::from_str(&std::fs::read_to_string(format!("{dir}/manifest.json")).unwrap())
1112                .unwrap();
1113
1114        // Tool-call shapes are omitted: they need a per-family `tools` payload,
1115        // and the hermetic tests already cover them.
1116        let sys = |c: &str| json!({"role": "system", "content": c});
1117        let usr = |c: &str| json!({"role": "user", "content": c});
1118        let asst = |c: &str| json!({"role": "assistant", "content": c});
1119        let shapes: Vec<(&str, serde_json::Value)> = vec![
1120            ("turn1", json!([sys("s"), usr("u"), sys("mid")])),
1121            (
1122                "multiturn",
1123                json!([sys("s"), usr("u"), sys("mid"), asst("a"), usr("u2")]),
1124            ),
1125            (
1126                "mid_after_asst",
1127                json!([sys("s"), usr("u"), asst("a"), sys("mid"), usr("u2")]),
1128            ),
1129            ("double_leading", json!([sys("s0"), sys("s1"), usr("u")])),
1130            ("consec_user", json!([sys("s"), usr("u0"), usr("u1")])),
1131            (
1132                "tail_reminder",
1133                json!([
1134                    sys("s"),
1135                    usr("u"),
1136                    asst("a"),
1137                    usr("u2"),
1138                    sys("mid"),
1139                    usr("u3")
1140                ]),
1141            ),
1142            ("leading_only_baseline", json!([sys("s"), usr("u")])),
1143        ];
1144
1145        let mut total = 0usize;
1146        let mut flagged = 0usize;
1147        let mut demote_only = 0usize;
1148        let mut coalesce = 0usize;
1149        let mut failures: Vec<String> = Vec::new();
1150        for (file, meta) in manifest.as_object().unwrap() {
1151            let tmpl = std::fs::read_to_string(format!("{dir}/{file}.jinja")).unwrap();
1152            let model = meta["model"].as_str().unwrap_or(file);
1153            // Some real templates use custom tags minijinja can't compile,
1154            // which says nothing about system normalization.
1155            let f = match try_formatter_for(&tmpl) {
1156                Some(f) => f,
1157                None => {
1158                    eprintln!("[skip-compile] {model}");
1159                    continue;
1160                }
1161            };
1162            // Vision templates need image inputs, so they can't render text-only
1163            // here. The probe's baseline guard leaves them unflagged anyway.
1164            if render_shape(&f, json!([sys("s"), usr("u")])).is_err() {
1165                eprintln!("[skip-baseline] {model}");
1166                continue;
1167            }
1168            total += 1;
1169            let rules = f.default_system_normalization;
1170            let flag = rules.is_required();
1171            if flag {
1172                flagged += 1;
1173            }
1174            if rules.demote_nonleading_system {
1175                demote_only += usize::from(!rules.coalesce_consecutive_users);
1176            }
1177            if rules.coalesce_consecutive_users {
1178                coalesce += 1;
1179            }
1180            for (name, shape) in &shapes {
1181                if render_shape(&f, shape.clone()).is_err() {
1182                    failures.push(format!("{model} | shape={name} | flag={flag}"));
1183                }
1184            }
1185            if flag {
1186                eprintln!(
1187                    "[ok] demote={} coalesce={} {model}",
1188                    rules.demote_nonleading_system, rules.coalesce_consecutive_users
1189                );
1190            }
1191        }
1192        eprintln!(
1193            "\naudited {total} templates ({flagged} flagged: {demote_only} demote-only, \
1194             {coalesce} coalescing); {} shape failures",
1195            failures.len()
1196        );
1197        for f in &failures {
1198            eprintln!("  FAIL {f}");
1199        }
1200        assert!(
1201            failures.is_empty(),
1202            "{} template/shape combinations did not render (probe insufficient or normalization insufficient)",
1203            failures.len()
1204        );
1205    }
1206
1207    /// End-to-end guard for the minijinja stack-overflow fix, exercised through
1208    /// Dynamo's real chat-template render path. A template that accumulates
1209    /// messages via `ns.items = ns.items + [m]` and then takes `|length`
1210    /// previously overflowed the native stack for long conversations (~1500+
1211    /// turns on a worker thread), core-dumping the frontend. Runs on a 2 MiB
1212    /// stack — the size of a Dynamo tokio worker thread — so a regression aborts
1213    /// deterministically instead of depending on the platform default.
1214    #[test]
1215    fn test_render_long_conversation_does_not_overflow_stack() {
1216        let handle = std::thread::Builder::new()
1217            .stack_size(2 * 1024 * 1024)
1218            .spawn(|| {
1219                let template_string = concat!(
1220                    "{%- set ns = namespace(items=[]) -%}",
1221                    "{%- for m in messages -%}",
1222                    "{%- set ns.items = ns.items + [m] -%}",
1223                    "{%- endfor -%}",
1224                    "COUNT={{ ns.items | length }}"
1225                );
1226                let chat_template: ChatTemplate =
1227                    serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
1228                        .unwrap();
1229                let formatter =
1230                    HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[]))
1231                        .unwrap();
1232
1233                let n = 3000;
1234                let messages: Vec<serde_json::Value> = (0..n)
1235                    .map(|i| serde_json::json!({"role": "user", "content": format!("turn {i}")}))
1236                    .collect();
1237                let request: NvCreateChatCompletionRequest =
1238                    serde_json::from_value(serde_json::json!({
1239                        "model": "test",
1240                        "messages": messages,
1241                    }))
1242                    .unwrap();
1243
1244                // The crash path: `|length` -> minijinja `Value::len()`.
1245                let rendered = formatter.render(&request).unwrap();
1246                assert_eq!(rendered.trim(), format!("COUNT={n}"));
1247            })
1248            .unwrap();
1249        handle.join().unwrap();
1250    }
1251
1252    /// Dev utility (ignored by default): dump the prompt Dynamo's renderer
1253    /// produces for a tool-calling chat request, so it can be diffed against
1254    /// vLLM's `openai_harmony` rendering — to see whether the gpt-oss Jinja
1255    /// `chat_template` actually emits the harmony "tool calls go to the
1256    /// commentary channel" guidance + `functions` namespace.
1257    ///
1258    /// Point GPTOSS_CHAT_TEMPLATE at the model's tokenizer_config.json (its
1259    /// `chat_template` field is extracted) OR a raw chat_template.jinja file:
1260    ///   GPTOSS_CHAT_TEMPLATE=/path/openai-gpt-oss-120b/tokenizer_config.json \
1261    ///     cargo test -p dynamo-renderer dump_gptoss_tool_prompt -- --ignored --nocapture
1262    #[test]
1263    #[ignore]
1264    fn dump_gptoss_tool_prompt() {
1265        use super::tokcfg::ChatTemplate;
1266        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
1267
1268        let path = std::env::var("GPTOSS_CHAT_TEMPLATE").expect(
1269            "set GPTOSS_CHAT_TEMPLATE to the tokenizer_config.json, chat_template.jinja, or model dir path",
1270        );
1271        let input_path = std::path::Path::new(&path);
1272        let file_path = if input_path.is_dir() {
1273            // Prefer tokenizer_config.json from model dir if provided
1274            input_path.join("tokenizer_config.json")
1275        } else {
1276            input_path.to_path_buf()
1277        };
1278        let raw = std::fs::read_to_string(&file_path).expect("read chat template file");
1279        // Resolve the actual Jinja template. gpt-oss ships its chat template in a
1280        // separate `chat_template.jinja` file, NOT inside tokenizer_config.json,
1281        // so:
1282        //   * if the file is JSON with a `chat_template` field, use it;
1283        //   * otherwise, if a sibling `chat_template.jinja` exists, read that;
1284        //   * otherwise treat the file itself as the template.
1285        let template_string: String = match serde_json::from_str::<serde_json::Value>(&raw) {
1286            Ok(v) if v.get("chat_template").is_some() => v["chat_template"]
1287                .as_str()
1288                .expect("chat_template field must be a string")
1289                .to_string(),
1290            _ => {
1291                let sibling = std::path::Path::new(&path)
1292                    .parent()
1293                    .map(|d| d.join("chat_template.jinja"));
1294                match sibling {
1295                    Some(p) if p.exists() => {
1296                        eprintln!(
1297                            "[info] {path} had no chat_template field; using {}",
1298                            p.display()
1299                        );
1300                        std::fs::read_to_string(&p).expect("read sibling chat_template.jinja")
1301                    }
1302                    _ => raw,
1303                }
1304            }
1305        };
1306
1307        // Guard against silently echoing a non-template (e.g. a tokenizer_config.json
1308        // with no chat_template and no sibling .jinja).
1309        assert!(
1310            template_string.contains("{%") || template_string.contains("{{"),
1311            "resolved template has no Jinja tags — GPTOSS_CHAT_TEMPLATE ({path}) is probably \
1312             tokenizer_config.json with no chat_template field and no sibling chat_template.jinja. \
1313             Point it at the chat_template.jinja file."
1314        );
1315
1316        let chat_template: ChatTemplate =
1317            serde_json::from_value(serde_json::json!({ "chat_template": template_string }))
1318                .unwrap();
1319
1320        let formatter =
1321            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
1322
1323        // Declare tools — the tool-channel guidance only renders when tools are present.
1324        let request: NvCreateChatCompletionRequest = serde_json::from_str(
1325            r#"{
1326              "model": "openai/gpt-oss-120b",
1327              "messages": [{"role":"user","content":"Search the repo for the string \"countHook\"."}],
1328              "tools": [
1329                {"type":"function","function":{"name":"grep","description":"search files","parameters":{"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}}},
1330                {"type":"function","function":{"name":"read","description":"read a file","parameters":{"type":"object","properties":{"filePath":{"type":"string"}},"required":["filePath"]}}}
1331              ]
1332            }"#,
1333        )
1334        .unwrap();
1335
1336        let rendered = formatter.render(&request).unwrap();
1337        eprintln!("================ RENDERED gpt-oss PROMPT (tools declared) ================");
1338        eprintln!("{rendered}");
1339        eprintln!("================ END RENDERED PROMPT ================");
1340        eprintln!("[diagnostics] does the rendered prompt contain…");
1341        for needle in [
1342            "commentary",
1343            "Calls to these tools",
1344            "functions",
1345            "# Tools",
1346            "<|channel|>",
1347            "constrain",
1348            "analysis",
1349        ] {
1350            eprintln!(
1351                "  {:>22}: {}",
1352                format!("{needle:?}"),
1353                rendered.contains(needle)
1354            );
1355        }
1356    }
1357
1358    /// Tests that media URL content parts are converted to empty placeholders.
1359    #[test]
1360    fn test_convert_media_url_to_placeholder_single_type() {
1361        let mut content_array = vec![
1362            serde_json::json!({"type": "text", "text": "Check this image:"}),
1363            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1364            serde_json::json!({"type": "text", "text": "What do you see?"}),
1365        ];
1366
1367        let conversions = &[("image_url", "image")];
1368        convert_media_url_to_placeholder(&mut content_array, conversions);
1369
1370        assert_eq!(content_array.len(), 3);
1371        // Text parts should be unchanged
1372        assert_eq!(content_array[0]["type"], "text");
1373        assert_eq!(content_array[0]["text"], "Check this image:");
1374        // image_url should be converted to image placeholder
1375        assert_eq!(content_array[1]["type"], "image");
1376        assert!(content_array[1].get("image_url").is_none());
1377        // Text parts should be unchanged
1378        assert_eq!(content_array[2]["type"], "text");
1379        assert_eq!(content_array[2]["text"], "What do you see?");
1380    }
1381
1382    /// Tests that multiple media URL parts of the same type are all converted.
1383    #[test]
1384    fn test_convert_media_url_to_placeholder_multiple_same_type() {
1385        let mut content_array = vec![
1386            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}}),
1387            serde_json::json!({"type": "text", "text": "vs"}),
1388            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}),
1389        ];
1390
1391        let conversions = &[("image_url", "image")];
1392        convert_media_url_to_placeholder(&mut content_array, conversions);
1393
1394        assert_eq!(content_array.len(), 3);
1395        assert_eq!(content_array[0]["type"], "image");
1396        assert_eq!(content_array[1]["type"], "text");
1397        assert_eq!(content_array[2]["type"], "image");
1398    }
1399
1400    /// Tests that only specified media types are converted, others preserved.
1401    #[test]
1402    fn test_convert_media_url_to_placeholder_selective_conversion() {
1403        let mut content_array = vec![
1404            serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1405            serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1406            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1407        ];
1408
1409        // Only convert image_url
1410        let conversions = &[("image_url", "image")];
1411        convert_media_url_to_placeholder(&mut content_array, conversions);
1412
1413        assert_eq!(content_array.len(), 3);
1414        // audio_url and video_url should be preserved as-is
1415        assert_eq!(content_array[0]["type"], "audio_url");
1416        assert!(content_array[0].get("audio_url").is_some());
1417        assert_eq!(content_array[1]["type"], "video_url");
1418        assert!(content_array[1].get("video_url").is_some());
1419        // Only image_url should be converted
1420        assert_eq!(content_array[2]["type"], "image");
1421        assert!(content_array[2].get("image_url").is_none());
1422    }
1423
1424    /// Tests converting multiple different media types at once.
1425    #[test]
1426    fn test_convert_media_url_to_placeholder_multiple_types() {
1427        let mut content_array = vec![
1428            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1429            serde_json::json!({"type": "text", "text": "and listen to"}),
1430            serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1431            serde_json::json!({"type": "text", "text": "and watch"}),
1432            serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1433        ];
1434
1435        // Convert all media types
1436        let conversions = &[
1437            ("image_url", "image"),
1438            ("audio_url", "audio"),
1439            ("video_url", "video"),
1440        ];
1441        convert_media_url_to_placeholder(&mut content_array, conversions);
1442
1443        assert_eq!(content_array.len(), 5);
1444        assert_eq!(content_array[0]["type"], "image");
1445        assert!(content_array[0].get("image_url").is_none());
1446        assert_eq!(content_array[1]["type"], "text");
1447        assert_eq!(content_array[2]["type"], "audio");
1448        assert!(content_array[2].get("audio_url").is_none());
1449        assert_eq!(content_array[3]["type"], "text");
1450        assert_eq!(content_array[4]["type"], "video");
1451        assert!(content_array[4].get("video_url").is_none());
1452    }
1453
1454    /// Tests that empty conversions list preserves all content.
1455    #[test]
1456    fn test_convert_media_url_to_placeholder_no_conversions() {
1457        let mut content_array = vec![
1458            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1459            serde_json::json!({"type": "text", "text": "hello"}),
1460        ];
1461
1462        let conversions: &[(&str, &str)] = &[];
1463        convert_media_url_to_placeholder(&mut content_array, conversions);
1464
1465        assert_eq!(content_array.len(), 2);
1466        // Everything should be preserved as-is
1467        assert_eq!(content_array[0]["type"], "image_url");
1468        assert!(content_array[0].get("image_url").is_some());
1469        assert_eq!(content_array[1]["type"], "text");
1470    }
1471
1472    /// Tests that DEFAULT_MEDIA_TYPE_CONVERSIONS only converts image_url,
1473    /// and preserves other media types like video_url and audio_url.
1474    #[test]
1475    fn test_default_media_type_conversions_only_converts_image_url() {
1476        let mut content_array = vec![
1477            serde_json::json!({"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}),
1478            serde_json::json!({"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}),
1479            serde_json::json!({"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}}),
1480            serde_json::json!({"type": "text", "text": "hello"}),
1481        ];
1482
1483        // Use the actual DEFAULT_MEDIA_TYPE_CONVERSIONS
1484        convert_media_url_to_placeholder(&mut content_array, DEFAULT_MEDIA_TYPE_CONVERSIONS);
1485
1486        assert_eq!(content_array.len(), 4);
1487
1488        // image_url SHOULD be converted to image (it's in the default map)
1489        assert_eq!(content_array[0]["type"], "image");
1490        assert!(content_array[0].get("image_url").is_none());
1491
1492        // video_url should NOT be converted (not in the default map)
1493        assert_eq!(content_array[1]["type"], "video");
1494        assert!(content_array[1].get("video_url").is_none());
1495
1496        // audio_url should NOT be converted (not in the default map)
1497        assert_eq!(content_array[2]["type"], "audio");
1498        assert!(content_array[2].get("audio_url").is_none());
1499
1500        // text should be unchanged
1501        assert_eq!(content_array[3]["type"], "text");
1502        assert_eq!(content_array[3]["text"], "hello");
1503    }
1504
1505    #[test]
1506    fn test_may_be_fix_tool_schema_missing_type_and_properties() {
1507        let json_str = r#"{
1508            "model": "gpt-4o",
1509            "messages": [],
1510            "tools": [
1511                {
1512                    "type": "function",
1513                    "function": {
1514                        "name": "get_weather",
1515                        "description": "Get the current weather in a given location",
1516                        "parameters": {},
1517                        "strict": null
1518                    }
1519                }
1520            ]
1521        }"#;
1522
1523        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1524        let tools = serde_json::to_value(request.tools()).unwrap();
1525
1526        assert!(tools[0]["function"]["parameters"]["type"] == "object");
1527        assert!(
1528            tools[0]["function"]["parameters"]["properties"]
1529                == serde_json::Value::Object(Default::default())
1530        );
1531    }
1532
1533    #[test]
1534    fn test_may_be_fix_tool_schema_missing_type() {
1535        let json_str = r#"{
1536            "model": "gpt-4o",
1537            "messages": [],
1538            "tools": [
1539                {
1540                    "type": "function",
1541                    "function": {
1542                        "name": "get_weather",
1543                        "description": "Get the current weather in a given location",
1544                        "parameters": {
1545                            "properties": {
1546                                "location": {
1547                                    "type": "string",
1548                                    "description": "City and state, e.g., 'San Francisco, CA'"
1549                                }
1550                            }
1551                        },
1552                        "strict": null
1553                    }
1554                }
1555            ]
1556        }"#;
1557        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1558
1559        let tools = serde_json::to_value(request.tools()).unwrap();
1560
1561        assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
1562
1563        let mut expected_properties = serde_json::Map::new();
1564        let mut location = serde_json::Map::new();
1565        location.insert(
1566            "type".to_string(),
1567            serde_json::Value::String("string".to_string()),
1568        );
1569        location.insert(
1570            "description".to_string(),
1571            serde_json::Value::String("City and state, e.g., 'San Francisco, CA'".to_string()),
1572        );
1573        expected_properties.insert("location".to_string(), serde_json::Value::Object(location));
1574
1575        assert_eq!(
1576            tools[0]["function"]["parameters"]["properties"],
1577            serde_json::Value::Object(expected_properties)
1578        );
1579    }
1580
1581    #[test]
1582    fn test_may_be_fix_tool_schema_missing_properties() {
1583        let json_str = r#"{
1584            "model": "gpt-4o",
1585            "messages": [],
1586            "tools": [
1587                {
1588                    "type": "function",
1589                    "function": {
1590                        "name": "get_weather",
1591                        "description": "Get the current weather in a given location",
1592                        "parameters": {"type": "object"},
1593                        "strict": null
1594                    }
1595                }
1596            ]
1597        }"#;
1598
1599        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1600        let tools = serde_json::to_value(request.tools()).unwrap();
1601
1602        assert_eq!(
1603            tools[0]["function"]["parameters"]["properties"],
1604            serde_json::Value::Object(Default::default())
1605        );
1606        assert_eq!(tools[0]["function"]["parameters"]["type"], "object");
1607    }
1608
1609    #[test]
1610    fn test_may_be_fix_tool_schema_missing_description() {
1611        // `description` is optional in the OpenAI tool schema, but some chat
1612        // templates (e.g. gpt-oss harmony) concatenate it unconditionally and
1613        // fail on an `undefined`/null value. It must be backfilled to "".
1614        let json_str = r#"{
1615            "model": "gpt-4o",
1616            "messages": [],
1617            "tools": [
1618                {
1619                    "type": "function",
1620                    "function": {
1621                        "name": "noop",
1622                        "parameters": {
1623                            "type": "object",
1624                            "properties": { "x": { "type": "string" } },
1625                            "required": ["x"],
1626                            "additionalProperties": false
1627                        },
1628                        "strict": null
1629                    }
1630                }
1631            ]
1632        }"#;
1633
1634        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1635        let tools = serde_json::to_value(request.tools()).unwrap();
1636
1637        assert_eq!(
1638            tools[0]["function"]["description"],
1639            serde_json::Value::String(String::new())
1640        );
1641    }
1642
1643    #[test]
1644    fn test_may_be_fix_tool_schema_null_description() {
1645        // An explicit null `description` must also be normalized to "".
1646        let json_str = r#"{
1647            "model": "gpt-4o",
1648            "messages": [],
1649            "tools": [
1650                {
1651                    "type": "function",
1652                    "function": {
1653                        "name": "noop",
1654                        "description": null,
1655                        "parameters": {"type": "object", "properties": {}},
1656                        "strict": null
1657                    }
1658                }
1659            ]
1660        }"#;
1661
1662        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1663        let tools = serde_json::to_value(request.tools()).unwrap();
1664
1665        assert_eq!(
1666            tools[0]["function"]["description"],
1667            serde_json::Value::String(String::new())
1668        );
1669    }
1670
1671    #[test]
1672    fn test_may_be_fix_tool_schema_preserves_description() {
1673        // A present `description` must be left untouched.
1674        let json_str = r#"{
1675            "model": "gpt-4o",
1676            "messages": [],
1677            "tools": [
1678                {
1679                    "type": "function",
1680                    "function": {
1681                        "name": "get_weather",
1682                        "description": "Get the current weather in a given location",
1683                        "parameters": {"type": "object", "properties": {}},
1684                        "strict": null
1685                    }
1686                }
1687            ]
1688        }"#;
1689
1690        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1691        let tools = serde_json::to_value(request.tools()).unwrap();
1692
1693        assert_eq!(
1694            tools[0]["function"]["description"],
1695            "Get the current weather in a given location"
1696        );
1697    }
1698
1699    /// Tests that content arrays (containing only text parts) are correctly concatenated.
1700    #[test]
1701    fn test_may_be_fix_msg_content_user_multipart() {
1702        let json_str = r#"{
1703            "model": "gpt-4o",
1704            "messages": [
1705                {
1706                    "role": "user",
1707                    "content": [
1708                        {"type": "text", "text": "part 1"},
1709                        {"type": "text", "text": "part 2"}
1710                    ]
1711                }
1712            ]
1713        }"#;
1714
1715        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1716        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1717
1718        // Test array → string normalization (preserve_arrays=false for standard templates)
1719        let messages =
1720            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1721
1722        // Verify: text-only array is concatenated into a single string
1723        assert_eq!(
1724            messages[0]["content"],
1725            serde_json::Value::String("part 1\npart 2".to_string())
1726        );
1727    }
1728
1729    /// Tests that the function correctly handles a conversation
1730    /// with multiple roles and mixed message types:
1731    #[test]
1732    fn test_may_be_fix_msg_content_mixed_messages() {
1733        let json_str = r#"{
1734            "model": "gpt-4o",
1735            "messages": [
1736                {
1737                    "role": "system",
1738                    "content": "You are a helpful assistant"
1739                },
1740                {
1741                    "role": "user",
1742                    "content": [
1743                        {"type": "text", "text": "Hello"},
1744                        {"type": "text", "text": "World"}
1745                    ]
1746                },
1747                {
1748                    "role": "assistant",
1749                    "content": "Hi there!"
1750                },
1751                {
1752                    "role": "user",
1753                    "content": [
1754                        {"type": "text", "text": "Another"},
1755                        {"type": "text", "text": "multi-part"},
1756                        {"type": "text", "text": "message"}
1757                    ]
1758                }
1759            ]
1760        }"#;
1761
1762        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1763        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1764
1765        // Test array → string normalization (preserve_arrays=false for standard templates)
1766        let messages =
1767            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1768
1769        // Verify: System message with string content remains unchanged
1770        assert_eq!(
1771            messages[0]["content"],
1772            serde_json::Value::String("You are a helpful assistant".to_string())
1773        );
1774
1775        // Verify: User message with text-only array is concatenated
1776        assert_eq!(
1777            messages[1]["content"],
1778            serde_json::Value::String("Hello\nWorld".to_string())
1779        );
1780
1781        // Verify: Assistant message with string content remains unchanged
1782        assert_eq!(
1783            messages[2]["content"],
1784            serde_json::Value::String("Hi there!".to_string())
1785        );
1786
1787        // Verify: Second user message with text-only array is concatenated
1788        assert_eq!(
1789            messages[3]["content"],
1790            serde_json::Value::String("Another\nmulti-part\nmessage".to_string())
1791        );
1792    }
1793
1794    /// Tests that empty content arrays remain unchanged.
1795    #[test]
1796    fn test_may_be_fix_msg_content_empty_array() {
1797        let json_str = r#"{
1798            "model": "gpt-4o",
1799            "messages": [
1800                {
1801                    "role": "user",
1802                    "content": []
1803                }
1804            ]
1805        }"#;
1806
1807        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1808        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1809
1810        // Empty arrays should be preserved regardless of preserve_arrays setting
1811        let messages =
1812            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1813
1814        // Verify: Empty arrays are preserved as-is
1815        assert!(messages[0]["content"].is_array());
1816        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1817    }
1818
1819    /// Empty arrays must stay as `[]` even when a flatten-time placeholder
1820    /// template is provided (Phi-3 / LLaVA-1.5 path). Without the
1821    /// `!content_array.is_empty()` guard in `may_be_fix_msg_content`,
1822    /// an empty content array would silently flatten to `""` and the
1823    /// chat template would render an entirely empty message instead of
1824    /// failing or being preserved.
1825    #[test]
1826    fn test_may_be_fix_msg_content_empty_array_with_placeholder_template() {
1827        let json_str = r#"{
1828            "model": "phi-3-vision",
1829            "messages": [
1830                {
1831                    "role": "user",
1832                    "content": []
1833                }
1834            ]
1835        }"#;
1836
1837        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1838        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1839
1840        // preserve_arrays=false + image_placeholder_template=Some(...) is
1841        // the combination that previously flattened `[]` to `""`.
1842        let messages = serde_json::to_value(may_be_fix_msg_content(
1843            messages_raw,
1844            false,
1845            Some("<|image_{n}|>"),
1846        ))
1847        .unwrap();
1848
1849        assert!(
1850            messages[0]["content"].is_array(),
1851            "empty array should be preserved as `[]`, not flattened to `\"\"`"
1852        );
1853        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 0);
1854    }
1855
1856    /// Tests that messages with simple string content remain unchanged.
1857    #[test]
1858    fn test_may_be_fix_msg_content_single_text() {
1859        let json_str = r#"{
1860            "model": "gpt-4o",
1861            "messages": [
1862                {
1863                    "role": "user",
1864                    "content": "Simple text message"
1865                }
1866            ]
1867        }"#;
1868
1869        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1870        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1871
1872        // Test with preserve_arrays=false (standard templates)
1873        let messages =
1874            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1875
1876        // Verify: String content is not modified
1877        assert_eq!(
1878            messages[0]["content"],
1879            serde_json::Value::String("Simple text message".to_string())
1880        );
1881    }
1882
1883    /// Tests that content arrays with mixed types (text + non-text) remain as arrays,
1884    /// and that image_url is converted to image placeholder.
1885    #[test]
1886    fn test_may_be_fix_msg_content_mixed_types() {
1887        let json_str = r#"{
1888            "model": "gpt-4o",
1889            "messages": [
1890                {
1891                    "role": "user",
1892                    "content": [
1893                        {"type": "text", "text": "Check this image:"},
1894                        {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
1895                        {"type": "text", "text": "What do you see?"}
1896                    ]
1897                }
1898            ]
1899        }"#;
1900
1901        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1902        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1903
1904        // Mixed content should be preserved regardless of preserve_arrays setting
1905        let messages =
1906            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
1907
1908        // Verify: Mixed content types are preserved as array for template handling
1909        // image_url should be converted to image placeholder
1910        assert!(messages[0]["content"].is_array());
1911        let content_array = messages[0]["content"].as_array().unwrap();
1912        assert_eq!(content_array.len(), 3);
1913        assert_eq!(content_array[0]["type"], "text");
1914        assert_eq!(content_array[1]["type"], "image");
1915        assert!(content_array[1].get("image_url").is_none());
1916        assert_eq!(content_array[2]["type"], "text");
1917    }
1918
1919    /// Mixed text+image array with a string-content template and an
1920    /// `<|image_{n}|>`-style placeholder (Phi-3-vision) — content must be
1921    /// flattened to a single string with numbered image markers in place of
1922    /// the image parts. The previous default (leave as array) would crash
1923    /// the Phi-3 template's `'+' message.content` concatenation.
1924    #[test]
1925    fn test_may_be_fix_msg_content_flattens_phi3_style() {
1926        let json_str = r#"{
1927            "model": "phi-3-vision",
1928            "messages": [
1929                {
1930                    "role": "user",
1931                    "content": [
1932                        {"type": "text", "text": "First "},
1933                        {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}},
1934                        {"type": "text", "text": " then "},
1935                        {"type": "image_url", "image_url": {"url": "https://example.com/b.jpg"}},
1936                        {"type": "text", "text": "?"}
1937                    ]
1938                }
1939            ]
1940        }"#;
1941        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1942        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1943
1944        let messages = serde_json::to_value(may_be_fix_msg_content(
1945            messages_raw,
1946            false,
1947            Some("<|image_{n}|>"),
1948        ))
1949        .unwrap();
1950
1951        let content = messages[0]["content"].as_str().expect("content flattened");
1952        assert_eq!(content, "First <|image_1|> then <|image_2|>?");
1953    }
1954
1955    /// Same flattening with a static placeholder (LLaVA-1.5 `<image>`).
1956    #[test]
1957    fn test_may_be_fix_msg_content_flattens_llava_style() {
1958        let json_str = r#"{
1959            "model": "llava-1.5-7b-hf",
1960            "messages": [
1961                {
1962                    "role": "user",
1963                    "content": [
1964                        {"type": "text", "text": "Describe: "},
1965                        {"type": "image_url", "image_url": {"url": "https://example.com/x.jpg"}}
1966                    ]
1967                }
1968            ]
1969        }"#;
1970        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
1971        let messages_raw = serde_json::to_value(request.messages()).unwrap();
1972
1973        let messages =
1974            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some("<image>")))
1975                .unwrap();
1976
1977        let content = messages[0]["content"].as_str().expect("content flattened");
1978        assert_eq!(content, "Describe: <image>");
1979    }
1980
1981    /// Nemotron-Parse pass-through path: a mixed text+image array with an
1982    /// empty placeholder (`""`) flattens to the text parts only — the image
1983    /// contributes nothing because the vision encoder consumes it out-of-band.
1984    /// Without this, `{{ message.content }}` would JSON-serialize the array
1985    /// into the prompt (the gibberish failure mode).
1986    #[test]
1987    fn test_may_be_fix_msg_content_flattens_empty_placeholder() {
1988        let json_str = r#"{
1989            "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
1990            "messages": [
1991                {
1992                    "role": "user",
1993                    "content": [
1994                        {"type": "text", "text": "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"},
1995                        {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
1996                    ]
1997                }
1998            ]
1999        }"#;
2000        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2001        let messages_raw = serde_json::to_value(request.messages()).unwrap();
2002
2003        let messages =
2004            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, Some(""))).unwrap();
2005
2006        let content = messages[0]["content"].as_str().expect("content flattened");
2007        assert_eq!(
2008            content,
2009            "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
2010        );
2011    }
2012
2013    /// End-to-end render through the Nemotron-Parse pass-through chat template:
2014    /// a text+image chat request must produce exactly the control-token prompt,
2015    /// with the image dropped from the rendered text. The renderer is agnostic
2016    /// to the control tokens themselves (they are just the text part, passed
2017    /// through verbatim), so both `predict_no_text_in_pic` and
2018    /// `predict_text_in_pic` prompts round-trip identically.
2019    #[test]
2020    fn test_render_nemotron_parse_passthrough() {
2021        use super::super::tokcfg::ChatTemplate;
2022        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2023
2024        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2025            "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}"
2026        }))
2027        .unwrap();
2028        let formatter =
2029            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2030
2031        for prompt in [
2032            "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>",
2033            "</s><s><predict_bbox><predict_classes><output_markdown><predict_text_in_pic>",
2034        ] {
2035            let request: NvCreateChatCompletionRequest =
2036                serde_json::from_value(serde_json::json!({
2037                    "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
2038                    "messages": [{
2039                        "role": "user",
2040                        "content": [
2041                            {"type": "text", "text": prompt},
2042                            {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
2043                        ]
2044                    }]
2045                }))
2046                .unwrap();
2047
2048            let rendered = formatter.render(&request).unwrap();
2049            assert_eq!(
2050                rendered, prompt,
2051                "rendered prompt must be the control tokens only, with no JSON-serialized image array"
2052            );
2053        }
2054    }
2055
2056    /// Tests that content arrays containing only non-text types remain as arrays,
2057    /// and image_url types are converted to image placeholders.
2058    #[test]
2059    fn test_may_be_fix_msg_content_non_text_only() {
2060        let json_str = r#"{
2061            "model": "gpt-4o",
2062            "messages": [
2063                {
2064                    "role": "user",
2065                    "content": [
2066                        {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}},
2067                        {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}}
2068                    ]
2069                }
2070            ]
2071        }"#;
2072
2073        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2074        let messages_raw = serde_json::to_value(request.messages()).unwrap();
2075
2076        // Non-text arrays should be preserved regardless of preserve_arrays setting
2077        let messages =
2078            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2079
2080        // Verify: Non-text content arrays are preserved, with image_url converted to image
2081        assert!(messages[0]["content"].is_array());
2082        let content_array = messages[0]["content"].as_array().unwrap();
2083        assert_eq!(content_array.len(), 2);
2084        assert_eq!(content_array[0]["type"], "image");
2085        assert_eq!(content_array[1]["type"], "image");
2086    }
2087
2088    #[test]
2089    fn test_none_tools_safe_for_all_templates() {
2090        use super::tokcfg::ChatTemplate;
2091        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2092
2093        // Due to minijinja limitations the expressions in conditional statements may not be short-circuited
2094        // This checks that our custom length filter works to avoid errors in this scenario
2095        // length should return 0 if tools is None and 'if tools is iterable and tools | length > 0' should evaluate to false
2096        let length_template = r#"
2097{%- if tools is iterable and tools | length > 0 %}
2098Tools available: {{ tools | length }}
2099{%- else %}
2100No tools
2101{%- endif %}
2102"#;
2103
2104        // Because we return None for tools when there are no tools this scenario should also be evaluate to false
2105        // This is similar to the default jinja template behavior seen with llama models which check if tools is not none to activate tool mode
2106        let no_tool_template = r#"
2107{%- if tools is not none %}
2108TOOL MODE
2109{%- else %}
2110NORMAL MODE
2111{%- endif %}
2112"#;
2113
2114        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2115            "chat_template": [
2116                {"safe_length": length_template},
2117                {"no_tool": no_tool_template}
2118            ]
2119        }))
2120        .unwrap();
2121
2122        let formatter =
2123            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2124
2125        let ctx = context! { tools => Option::<Value>::None };
2126
2127        let result1 = formatter
2128            .env
2129            .get_template("safe_length")
2130            .unwrap()
2131            .render(&ctx);
2132        println!("Safe length template with no tools => None: {:?}", result1);
2133        assert!(
2134            result1.is_ok(),
2135            "Jinja template with and conditional and length filter should handle None: {:?}",
2136            result1
2137        );
2138        assert!(
2139            result1.unwrap().contains("No tools"),
2140            "Should show 'No tools'"
2141        );
2142
2143        let result2 = formatter.env.get_template("no_tool").unwrap().render(&ctx);
2144        println!("Default template with no tools => None: {:?}", result2);
2145        assert!(
2146            result2.is_ok(),
2147            "Jinja template with if tools is not none conditional should handle None: {:?}",
2148            result2
2149        );
2150        assert!(result2.unwrap().contains("NORMAL MODE"));
2151    }
2152
2153    /// Tests mixed content type scenarios.
2154    #[test]
2155    fn test_may_be_fix_msg_content_multiple_content_types() {
2156        // Scenario 1: Multiple different content types (text + image + audio)
2157        let json_str = r#"{
2158            "model": "gpt-4o",
2159            "messages": [
2160                {
2161                    "role": "user",
2162                    "content": [
2163                        {"type": "text", "text": "Listen to this:"},
2164                        {"type": "audio_url", "audio_url": {"url": "https://example.com/audio.mp3"}},
2165                        {"type": "text", "text": "And look at:"},
2166                        {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
2167                        {"type": "text", "text": "What do you think?"}
2168                    ]
2169                }
2170            ]
2171        }"#;
2172
2173        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2174        let messages_raw = serde_json::to_value(request.messages()).unwrap();
2175        let messages =
2176            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2177
2178        // Mixed types should preserve array structure, with image_url converted to image
2179        assert!(messages[0]["content"].is_array());
2180        let content_array = messages[0]["content"].as_array().unwrap();
2181        assert_eq!(content_array.len(), 5);
2182        assert_eq!(content_array[0]["type"], "text");
2183        assert_eq!(content_array[1]["type"], "audio");
2184        assert_eq!(content_array[2]["type"], "text");
2185        assert_eq!(content_array[3]["type"], "image");
2186        assert_eq!(content_array[4]["type"], "text");
2187
2188        // Scenario 2: Unknown/future content types mixed with text
2189        let json_str = r#"{
2190            "model": "gpt-4o",
2191            "messages": [
2192                {
2193                    "role": "user",
2194                    "content": [
2195                        {"type": "text", "text": "Check this:"},
2196                        {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
2197                        {"type": "text", "text": "Interesting?"}
2198                    ]
2199                }
2200            ]
2201        }"#;
2202
2203        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2204        let messages_raw = serde_json::to_value(request.messages()).unwrap();
2205        let messages =
2206            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2207
2208        // Unknown types mixed with text should preserve array
2209        assert!(messages[0]["content"].is_array());
2210        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
2211    }
2212
2213    #[test]
2214    fn test_normalize_tool_arguments_tojson() {
2215        let tmpl = r#"{{ messages[0].tool_calls[0].function.arguments | tojson }}"#;
2216
2217        // Message with tool_calls containing JSON string arguments
2218        let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2219            "role": "assistant",
2220            "tool_calls": [{
2221                "type": "function",
2222                "function": {
2223                    "name": "get_current_weather",
2224                    "arguments": "{\"format\":\"celsius\",\"location\":\"San Francisco, CA\"}"
2225                }
2226            }]
2227        })]);
2228
2229        normalize_tool_calls_arguments_in_messages(&mut messages);
2230
2231        let mut env = Environment::new();
2232        env.add_filter("tojson", super::super::tokcfg::tojson);
2233        env.add_template("t", tmpl).unwrap();
2234        let out = env
2235            .get_template("t")
2236            .unwrap()
2237            .render(context! { messages => messages.as_array().unwrap() })
2238            .unwrap();
2239
2240        // Should produce clean JSON without double-encoding, with Python
2241        // json.dumps separators (what transformers' tojson emits).
2242        assert_eq!(
2243            out,
2244            r#"{"format": "celsius", "location": "San Francisco, CA"}"#
2245        );
2246    }
2247
2248    #[test]
2249    fn test_normalize_tool_arguments_items_loop() {
2250        let tmpl = r#"{% for k, v in messages[0].tool_calls[0].function.arguments|items %}{{k}}={{v}};{% endfor %}"#;
2251
2252        let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2253            "role": "assistant",
2254            "tool_calls": [{
2255                "type": "function",
2256                "function": {
2257                    "name": "f",
2258                    "arguments": "{\"a\":1,\"b\":\"x\"}"
2259                }
2260            }]
2261        })]);
2262
2263        normalize_tool_calls_arguments_in_messages(&mut messages);
2264
2265        let mut env = Environment::new();
2266        env.add_template("t", tmpl).unwrap();
2267        let out = env
2268            .get_template("t")
2269            .unwrap()
2270            .render(context! { messages => messages.as_array().unwrap() })
2271            .unwrap();
2272
2273        assert!(out == "a=1;b=x;" || out == "b=x;a=1;");
2274    }
2275
2276    #[test]
2277    fn test_normalize_tool_arguments_legacy_function_call() {
2278        // Test deprecated function_call format (OpenAI compat)
2279        let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2280            "role": "assistant",
2281            "function_call": {
2282                "name": "get_weather",
2283                "arguments": "{\"location\":\"NYC\"}"
2284            }
2285        })]);
2286
2287        normalize_function_call_arguments_in_messages(&mut messages);
2288
2289        assert_eq!(
2290            messages[0]["function_call"]["arguments"],
2291            serde_json::json!({"location": "NYC"})
2292        );
2293    }
2294
2295    #[test]
2296    fn test_normalize_tool_arguments_malformed_json_passthrough() {
2297        // Malformed JSON should be left as a string
2298        let mut messages = serde_json::Value::Array(vec![serde_json::json!({
2299            "role": "assistant",
2300            "tool_calls": [{
2301                "type": "function",
2302                "function": {
2303                    "name": "f",
2304                    "arguments": "not valid json at all"
2305                }
2306            }]
2307        })]);
2308
2309        normalize_tool_calls_arguments_in_messages(&mut messages);
2310
2311        assert_eq!(
2312            messages[0]["tool_calls"][0]["function"]["arguments"],
2313            serde_json::Value::String("not valid json at all".to_string())
2314        );
2315    }
2316
2317    #[test]
2318    fn test_normalize_tool_arguments_with_multimodal_content() {
2319        let json_str = r#"{
2320            "model": "gpt-4o",
2321            "messages": [
2322                {
2323                    "role": "user",
2324                    "content": [
2325                        {"type": "text", "text": "Check this:"},
2326                        {"type": "video_url", "video_url": {"url": "https://example.com/vid.mp4"}},
2327                        {"type": "text", "text": "Interesting?"}
2328                    ]
2329                },
2330                {
2331                    "role": "assistant",
2332                    "tool_calls": [{
2333                        "id": "call_123",
2334                        "type": "function",
2335                        "function": {
2336                            "name": "analyze_video",
2337                            "arguments": "{\"url\":\"https://example.com/vid.mp4\",\"format\":\"mp4\"}"
2338                        }
2339                    }]
2340                }
2341            ]
2342        }"#;
2343
2344        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2345        let messages_raw = serde_json::to_value(request.messages()).unwrap();
2346
2347        // Apply content normalization with preserve_arrays=false (standard templates)
2348        let mut messages =
2349            serde_json::to_value(may_be_fix_msg_content(messages_raw, false, None)).unwrap();
2350
2351        normalize_tool_calls_arguments_in_messages(&mut messages);
2352
2353        // Multimodal content preserved as array (mixed types not flattened)
2354        assert!(messages[0]["content"].is_array());
2355        assert_eq!(messages[0]["content"].as_array().unwrap().len(), 3);
2356
2357        // Tool arguments deserialized to object
2358        assert!(messages[1]["tool_calls"][0]["function"]["arguments"].is_object());
2359        assert_eq!(
2360            messages[1]["tool_calls"][0]["function"]["arguments"]["url"],
2361            "https://example.com/vid.mp4"
2362        );
2363    }
2364
2365    /// MiniMax-M3's `to_xml` prints history argument floats with `{{ val }}`;
2366    /// HF (vLLM/SGLang `json.loads` the arguments) renders Python `str(float)`.
2367    #[test]
2368    fn test_minimax_m3_history_tool_call_float_arguments_match_hf() {
2369        let template = r#"{%- set ns_token = ']<]minimax[>[' -%}
2370{%- macro to_xml(val, ns) -%}
2371{%- if val is mapping -%}
2372{%- for k, v in val.items() if v is not none -%}
2373{{ ns }}<{{ k }}>{{ to_xml(v, ns) }}{{ ns }}</{{ k }}>
2374{%- endfor -%}
2375{%- elif val is iterable and val is not string -%}
2376{%- for item in val -%}
2377{{ ns }}<item>{{ to_xml(item, ns) }}{{ ns }}</item>
2378{%- endfor -%}
2379{%- elif val is none -%}
2380{%- elif val is boolean -%}
2381{{ val | tojson }}
2382{%- else -%}
2383{{ val }}
2384{%- endif -%}
2385{%- endmacro -%}
2386{%- for message in messages if message.tool_calls -%}
2387{%- for tool_call in message.tool_calls -%}
2388{%- if tool_call.function -%}
2389{%- set tool_call = tool_call.function -%}
2390{%- endif -%}
2391{{- ns_token + '<invoke name="' + tool_call.name + '">' }}
2392{%- set _args = tool_call.arguments -%}
2393{%- for k, v in _args.items() if v is not none %}
2394{{- ns_token + '<' + k + '>' -}}
2395{{- to_xml(v, ns_token) -}}
2396{{- ns_token + '</' + k + '>' }}
2397{%- endfor -%}
2398{{- ns_token + '</invoke>' ~ '\n' }}
2399{%- endfor -%}
2400{%- endfor -%}"#;
2401        let rendered = render_shape(
2402            &formatter_for(template),
2403            json!([
2404                {"role": "user", "content": "u"},
2405                {"role": "assistant", "content": "", "tool_calls": [{
2406                    "id": "c1",
2407                    "type": "function",
2408                    "function": {
2409                        "name": "fit",
2410                        "arguments": r#"{"tolerance": 1e-07, "bounds": [0.00001, 1e16]}"#
2411                    }
2412                }]}
2413            ]),
2414        )
2415        .unwrap();
2416        assert_eq!(
2417            rendered,
2418            "]<]minimax[>[<invoke name=\"fit\">]<]minimax[>[<tolerance>1e-07]<]minimax[>[</tolerance>]<]minimax[>[<bounds>]<]minimax[>[<item>1e-05]<]minimax[>[</item>]<]minimax[>[<item>1e+16]<]minimax[>[</item>]<]minimax[>[</bounds>]<]minimax[>[</invoke>\n"
2419        );
2420    }
2421
2422    /// Qwen3-Coder prints scalar history arguments with `| string`, which HF
2423    /// renders as Python `str(float)`.
2424    #[test]
2425    fn test_qwen3_coder_history_tool_call_float_arguments_match_hf() {
2426        let template = r#"{%- for message in messages if message.tool_calls -%}
2427{%- for tool_call in message.tool_calls %}
2428    {%- if tool_call.function is defined %}
2429        {%- set tool_call = tool_call.function %}
2430    {%- endif %}
2431    {%- for args_name, args_value in tool_call.arguments|items %}
2432        {{- '<parameter=' + args_name + '>\n' }}
2433        {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
2434        {{- args_value }}
2435        {{- '\n</parameter>\n' }}
2436    {%- endfor %}
2437{%- endfor %}
2438{%- endfor %}"#;
2439        let rendered = render_shape(
2440            &formatter_for(template),
2441            json!([
2442                {"role": "user", "content": "u"},
2443                {"role": "assistant", "content": "", "tool_calls": [{
2444                    "id": "c1",
2445                    "type": "function",
2446                    "function": {
2447                        "name": "fit",
2448                        "arguments": r#"{"tolerance": 1e-07, "bounds": [0.00001, 1e16]}"#
2449                    }
2450                }]}
2451            ]),
2452        )
2453        .unwrap();
2454        assert_eq!(
2455            rendered,
2456            "<parameter=tolerance>\n1e-07\n</parameter>\n<parameter=bounds>\n[1e-05, 1e+16]\n</parameter>\n"
2457        );
2458    }
2459
2460    /// Tests string → array normalization for multimodal templates
2461    #[test]
2462    fn test_may_be_fix_msg_content_string_to_array() {
2463        let json_str = r#"{
2464            "model": "gpt-4o",
2465            "messages": [
2466                {
2467                    "role": "user",
2468                    "content": "Hello, how are you?"
2469                }
2470            ]
2471        }"#;
2472
2473        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2474        let messages_raw = serde_json::to_value(request.messages()).unwrap();
2475
2476        // Test with preserve_arrays=true (multimodal templates)
2477        let messages =
2478            serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
2479
2480        // Verify: String is converted to array format
2481        assert!(messages[0]["content"].is_array());
2482        let content_array = messages[0]["content"].as_array().unwrap();
2483        assert_eq!(content_array.len(), 1);
2484        assert_eq!(content_array[0]["type"], "text");
2485        assert_eq!(content_array[0]["text"], "Hello, how are you?");
2486    }
2487
2488    /// Tests that arrays are preserved when preserve_arrays=true
2489    #[test]
2490    fn test_may_be_fix_msg_content_array_preserved_with_multimodal() {
2491        let json_str = r#"{
2492            "model": "gpt-4o",
2493            "messages": [
2494                {
2495                    "role": "user",
2496                    "content": [
2497                        {"type": "text", "text": "part 1"},
2498                        {"type": "text", "text": "part 2"}
2499                    ]
2500                }
2501            ]
2502        }"#;
2503
2504        let request: NvCreateChatCompletionRequest = serde_json::from_str(json_str).unwrap();
2505        let messages_raw = serde_json::to_value(request.messages()).unwrap();
2506
2507        // Test with preserve_arrays=true (multimodal templates)
2508        let messages =
2509            serde_json::to_value(may_be_fix_msg_content(messages_raw, true, None)).unwrap();
2510
2511        // Verify: Array is preserved as-is
2512        assert!(messages[0]["content"].is_array());
2513        let content_array = messages[0]["content"].as_array().unwrap();
2514        assert_eq!(content_array.len(), 2);
2515        assert_eq!(content_array[0]["text"], "part 1");
2516        assert_eq!(content_array[1]["text"], "part 2");
2517    }
2518
2519    fn user() -> Msg {
2520        Msg::User(Default::default())
2521    }
2522    fn tool() -> Msg {
2523        Msg::Tool(Default::default())
2524    }
2525
2526    fn dummy_state(messages: Vec<Msg>) -> NvCreateChatCompletionRequest {
2527        let json = serde_json::json!({
2528            "model": "test-model",
2529            "messages": messages
2530        });
2531        serde_json::from_value(json).unwrap()
2532    }
2533
2534    #[test]
2535    fn add_after_user() {
2536        let s = dummy_state(vec![user()]);
2537        assert!(s.should_add_generation_prompt());
2538    }
2539
2540    #[test]
2541    fn add_after_tool() {
2542        let s = dummy_state(vec![tool()]);
2543        assert!(s.should_add_generation_prompt());
2544    }
2545
2546    #[test]
2547    fn add_when_empty() {
2548        let s = dummy_state(vec![]);
2549        assert!(s.should_add_generation_prompt());
2550    }
2551
2552    /// Helper to build a formatter with a simple tool-aware template.
2553    fn tool_aware_formatter(
2554        exclude_tools_when_tool_choice_none: bool,
2555    ) -> HfTokenizerConfigJsonFormatter {
2556        let template = r#"
2557{%- if tools is iterable and tools | length > 0 %}
2558TOOL_MODE tools={{ tools | length }}
2559{%- else %}
2560NORMAL_MODE
2561{%- endif %}
2562{{ messages[0].content }}"#;
2563
2564        let chat_template: super::tokcfg::ChatTemplate =
2565            serde_json::from_value(serde_json::json!({ "chat_template": template })).unwrap();
2566
2567        HfTokenizerConfigJsonFormatter::with_options(
2568            chat_template,
2569            ContextMixins::new(&[]),
2570            exclude_tools_when_tool_choice_none,
2571        )
2572        .unwrap()
2573    }
2574
2575    fn gemma4_tool_template_for_tests() -> &'static str {
2576        r#"
2577{{ bos_token }}
2578{%- set loop_messages = messages -%}
2579{%- set ns_turn = namespace(last_user_idx=-1) -%}
2580{%- for i in range(loop_messages | length) -%}
2581    {%- if loop_messages[i]['role'] == 'user' -%}
2582        {%- set ns_turn.last_user_idx = i -%}
2583    {%- endif -%}
2584{%- endfor -%}
2585{%- for message in loop_messages -%}
2586    {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
2587    {{- '<|turn>' + role + '\n' }}
2588
2589    {%- if message.get('reasoning') and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
2590        {{- '<|channel>thought\n' + message['reasoning'] + '\n<channel|>'}}
2591    {%- endif -%}
2592
2593            {%- if message['tool_calls'] -%}
2594                {%- for tool_call in message['tool_calls'] -%}
2595                    {%- set function = tool_call['function'] -%}
2596                    {{- '<|tool_call>call:' + function['name'] + '{' -}}
2597                    {%- if function['arguments'] is mapping -%}
2598                        {%- set ns_args = namespace(found_first=false) -%}
2599                        {%- for key, value in function['arguments'] | dictsort -%}
2600                            {%- if ns_args.found_first %},{% endif -%}
2601                            {%- set ns_args.found_first = true -%}
2602                            {{- key -}}:{{- value -}}
2603                        {%- endfor -%}
2604                    {%- elif function['arguments'] is string -%}
2605                        {{- function['arguments'] -}}
2606                    {%- endif -%}
2607                    {{- '}<tool_call|>' -}}
2608                {%- endfor -%}
2609            {%- endif -%}
2610
2611            {%- if message['content'] is string -%}
2612                {{- message['content'] -}}
2613            {%- endif -%}
2614    {{- '<turn|>\n' -}}
2615{%- endfor -%}
2616"#
2617    }
2618
2619    fn make_gemma4_tool_formatter_for_tests() -> HfTokenizerConfigJsonFormatter {
2620        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2621            "chat_template": gemma4_tool_template_for_tests()
2622        }))
2623        .unwrap();
2624        HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2625    }
2626
2627    /// Helper to build a request with tools and optional tool_choice.
2628    fn request_with_tool_choice(tool_choice: &str) -> NvCreateChatCompletionRequest {
2629        serde_json::from_value(serde_json::json!({
2630            "model": "test",
2631            "messages": [{"role": "user", "content": "hello"}],
2632            "tools": [{
2633                "type": "function",
2634                "function": {
2635                    "name": "get_weather",
2636                    "description": "Get weather",
2637                    "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}
2638                }
2639            }],
2640            "tool_choice": tool_choice
2641        }))
2642        .unwrap()
2643    }
2644
2645    #[test]
2646    fn test_exclude_tools_strips_when_tool_choice_none() {
2647        let formatter = tool_aware_formatter(true);
2648        let request = request_with_tool_choice("none");
2649        let result = formatter.render(&request).unwrap();
2650        assert!(
2651            result.contains("NORMAL_MODE"),
2652            "With exclude_tools=true and tool_choice=none, tools should be stripped. Got: {}",
2653            result
2654        );
2655    }
2656
2657    #[test]
2658    fn test_exclude_tools_keeps_when_tool_choice_auto() {
2659        let formatter = tool_aware_formatter(true);
2660        let request = request_with_tool_choice("auto");
2661        let result = formatter.render(&request).unwrap();
2662        assert!(
2663            result.contains("TOOL_MODE"),
2664            "With tool_choice=auto, tools should be included. Got: {}",
2665            result
2666        );
2667    }
2668
2669    #[test]
2670    fn test_no_exclude_tools_keeps_when_tool_choice_none() {
2671        let formatter = tool_aware_formatter(false);
2672        let request = request_with_tool_choice("none");
2673        let result = formatter.render(&request).unwrap();
2674        assert!(
2675            result.contains("TOOL_MODE"),
2676            "With exclude_tools=false and tool_choice=none, tools should NOT be stripped. Got: {}",
2677            result
2678        );
2679    }
2680
2681    #[test]
2682    fn test_inject_reasoning_content_segments_with_tool_calls() {
2683        // Assistant message with reasoning_content segments and tool_calls
2684        let mut messages = serde_json::json!([
2685            {
2686                "role": "user",
2687                "content": "What is sqrt(144) and sqrt(256)?"
2688            },
2689            {
2690                "role": "assistant",
2691                "content": "Let me calculate those.",
2692                "reasoning_content": ["I need to compute sqrt(144)", "Now sqrt(256)", ""],
2693                "tool_calls": [
2694                    {
2695                        "id": "call_0",
2696                        "type": "function",
2697                        "function": {
2698                            "name": "calculator",
2699                            "arguments": "{\"expr\": \"sqrt(144)\"}"
2700                        }
2701                    },
2702                    {
2703                        "id": "call_1",
2704                        "type": "function",
2705                        "function": {
2706                            "name": "calculator",
2707                            "arguments": "{\"expr\": \"sqrt(256)\"}"
2708                        }
2709                    }
2710                ]
2711            }
2712        ]);
2713
2714        inject_reasoning_content_into_messages(&mut messages);
2715
2716        let assistant = &messages[1];
2717
2718        // reasoning_content should be removed
2719        assert!(
2720            assistant.get("reasoning_content").is_none(),
2721            "reasoning_content should be removed after injection"
2722        );
2723
2724        // content should have <think> blocks prepended (empty segment skipped)
2725        let content = assistant["content"].as_str().unwrap();
2726        assert!(
2727            content.starts_with("<think>I need to compute sqrt(144)</think>"),
2728            "content should start with first reasoning segment, got: {}",
2729            content
2730        );
2731        assert!(
2732            content.contains("<think>Now sqrt(256)</think>"),
2733            "content should contain second reasoning segment"
2734        );
2735        // Empty third segment should NOT produce <think></think>
2736        assert!(
2737            !content.contains("<think></think>"),
2738            "empty segments should be skipped"
2739        );
2740        // Original content should be preserved at the end
2741        assert!(
2742            content.ends_with("Let me calculate those."),
2743            "original content should be at the end, got: {}",
2744            content
2745        );
2746
2747        // tool_calls should be untouched
2748        assert!(assistant.get("tool_calls").is_some());
2749        assert_eq!(assistant["tool_calls"].as_array().unwrap().len(), 2);
2750    }
2751
2752    #[test]
2753    fn test_gemma4_template_renders_reasoning_content_segments_around_tool_calls() {
2754        let formatter = make_gemma4_tool_formatter_for_tests();
2755        assert!(
2756            formatter.tool_use_template_handles_reasoning,
2757            "Gemma4 template adaptation should make reasoning_content native"
2758        );
2759
2760        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2761            "model": "gemma4-test",
2762            "messages": [
2763                {"role": "user", "content": "inspect two things"},
2764                {
2765                    "role": "assistant",
2766                    "content": null,
2767                    "reasoning_content": [
2768                        "Think before the first call.",
2769                        "Think before the second call.",
2770                        "Think after both calls."
2771                    ],
2772                    "tool_calls": [
2773                        {
2774                            "id": "call_0",
2775                            "type": "function",
2776                            "function": {
2777                                "name": "first_tool",
2778                                "arguments": "{\"path\":\".\"}"
2779                            }
2780                        },
2781                        {
2782                            "id": "call_1",
2783                            "type": "function",
2784                            "function": {
2785                                "name": "second_tool",
2786                                "arguments": "{\"path\":\"/tmp\"}"
2787                            }
2788                        }
2789                    ]
2790                }
2791            ]
2792        }))
2793        .unwrap();
2794
2795        let rendered = formatter.render(&request).unwrap();
2796
2797        let expected = concat!(
2798            "<|channel>thought\nThink before the first call.\n<channel|>",
2799            "<|tool_call>call:first_tool{path:.}<tool_call|>",
2800            "<|channel>thought\nThink before the second call.\n<channel|>",
2801            "<|tool_call>call:second_tool{path:/tmp}<tool_call|>",
2802            "<|channel>thought\nThink after both calls.\n<channel|>"
2803        );
2804        assert!(
2805            rendered.contains(expected),
2806            "Gemma4 reasoning segments should stay adjacent to their tool calls, got: {rendered}"
2807        );
2808        assert!(!rendered.contains("<think>"));
2809        assert!(!rendered.contains("reasoning_content"));
2810    }
2811
2812    #[test]
2813    fn test_gemma4_template_renders_reasoning_content_without_tool_calls() {
2814        let formatter = make_gemma4_tool_formatter_for_tests();
2815        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2816            "model": "gemma4-test",
2817            "messages": [
2818                {"role": "user", "content": "answer directly"},
2819                {
2820                    "role": "assistant",
2821                    "content": "Direct answer.",
2822                    "reasoning_content": "Private thought."
2823                }
2824            ]
2825        }))
2826        .unwrap();
2827
2828        let rendered = formatter.render(&request).unwrap();
2829
2830        assert!(
2831            rendered.contains("<|channel>thought\nPrivate thought.\n<channel|>Direct answer."),
2832            "Gemma4 reasoning_content should render in the thought channel, got: {rendered}"
2833        );
2834        assert!(!rendered.contains("<think>"));
2835        assert!(!rendered.contains("reasoning_content"));
2836    }
2837
2838    /// Regression: when a config ships a separate non-tool `default` template
2839    /// (dict form), adapting only the `tool_use` template to read
2840    /// `reasoning_content` must NOT suppress `<think>` injection on the
2841    /// `default` path. A global `any()` flag would flip true off the adapted
2842    /// `tool_use` template and silently drop reasoning on no-tool renders.
2843    #[test]
2844    fn test_reasoning_flag_is_per_template_not_global() {
2845        // Plain default template: renders content, never mentions reasoning_content
2846        // and lacks the Gemma4 fingerprint, so it is left untouched.
2847        const PLAIN_DEFAULT: &str = "{{ bos_token }}{%- for message in messages -%}\
2848            {{ message['role'] }}: {{ message['content'] }}\n{%- endfor -%}";
2849
2850        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2851            "chat_template": [
2852                {"default": PLAIN_DEFAULT},
2853                {"tool_use": gemma4_tool_template_for_tests()},
2854            ]
2855        }))
2856        .unwrap();
2857        let formatter =
2858            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
2859
2860        // The adapted Gemma4 tool_use template handles reasoning natively; the
2861        // untouched plain default template does not. The flag must reflect that
2862        // per-template split, not a global OR across both.
2863        assert!(
2864            formatter.tool_use_template_handles_reasoning,
2865            "adapted gemma4 tool_use template should handle reasoning natively"
2866        );
2867        assert!(
2868            !formatter.default_template_handles_reasoning,
2869            "plain default template does not reference reasoning_content"
2870        );
2871
2872        // A no-tools request routes to `default`. Reasoning must still be injected
2873        // as a <think> block — not silently dropped by the tool_use template's flag.
2874        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2875            "model": "gemma4-test",
2876            "messages": [
2877                {"role": "user", "content": "answer directly"},
2878                {
2879                    "role": "assistant",
2880                    "content": "Direct answer.",
2881                    "reasoning_content": "Private thought."
2882                }
2883            ]
2884        }))
2885        .unwrap();
2886
2887        let rendered = formatter.render(&request).unwrap();
2888        assert!(
2889            rendered.contains("<think>Private thought.</think>Direct answer."),
2890            "reasoning must be injected on the no-tool default path, got: {rendered}"
2891        );
2892    }
2893
2894    #[test]
2895    fn test_inject_reasoning_content_text_variant() {
2896        let mut messages = serde_json::json!([
2897            {
2898                "role": "assistant",
2899                "content": "The answer is 42.",
2900                "reasoning_content": "Let me think about this carefully."
2901            }
2902        ]);
2903
2904        inject_reasoning_content_into_messages(&mut messages);
2905
2906        let assistant = &messages[0];
2907        assert!(assistant.get("reasoning_content").is_none());
2908        let content = assistant["content"].as_str().unwrap();
2909        assert_eq!(
2910            content,
2911            "<think>Let me think about this carefully.</think>The answer is 42."
2912        );
2913    }
2914
2915    #[test]
2916    fn test_inject_reasoning_content_null_content() {
2917        // reasoning_content present but content is null
2918        let mut messages = serde_json::json!([
2919            {
2920                "role": "assistant",
2921                "content": null,
2922                "reasoning_content": "Thinking...",
2923                "tool_calls": [{"id": "call_0", "type": "function", "function": {"name": "f", "arguments": "{}"}}]
2924            }
2925        ]);
2926
2927        inject_reasoning_content_into_messages(&mut messages);
2928
2929        let content = messages[0]["content"].as_str().unwrap();
2930        assert_eq!(content, "<think>Thinking...</think>");
2931        assert!(messages[0].get("reasoning_content").is_none());
2932    }
2933
2934    #[test]
2935    fn test_inject_reasoning_content_skips_non_assistant() {
2936        let mut messages = serde_json::json!([
2937            {
2938                "role": "user",
2939                "content": "hello",
2940                "reasoning_content": "should not be touched"
2941            }
2942        ]);
2943
2944        inject_reasoning_content_into_messages(&mut messages);
2945
2946        // User message should be untouched
2947        assert!(messages[0].get("reasoning_content").is_some());
2948    }
2949
2950    // Helper: create a formatter with a minimal chat template for render tests
2951    fn make_test_formatter() -> HfTokenizerConfigJsonFormatter {
2952        use super::tokcfg::ChatTemplate;
2953        use super::{ContextMixins, HfTokenizerConfigJsonFormatter};
2954
2955        // Minimal template that renders content verbatim — enough to verify
2956        // that reasoning_content injection works through the full pipeline.
2957        let template = r#"{%- for message in messages %}{{ message.role }}: {{ message.content }}
2958{%- endfor %}
2959{%- if add_generation_prompt %}assistant:{%- endif %}"#;
2960
2961        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
2962            "chat_template": template
2963        }))
2964        .unwrap();
2965
2966        HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
2967    }
2968
2969    // Verify reasoning_content (Text variant) from a prior assistant turn
2970    // appears as a <think> block in the rendered prompt.
2971    #[test]
2972    fn test_reasoning_content_text_roundtrip_render() {
2973        use super::OAIPromptFormatter;
2974        let formatter = make_test_formatter();
2975
2976        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2977            "model": "test-model",
2978            "messages": [
2979                {"role": "user", "content": "What is sqrt(144)?"},
2980                {
2981                    "role": "assistant",
2982                    "content": "The answer is 12.",
2983                    "reasoning_content": "I need to compute the square root of 144."
2984                },
2985                {"role": "user", "content": "Are you sure?"}
2986            ]
2987        }))
2988        .unwrap();
2989
2990        let rendered = formatter.render(&request).unwrap();
2991
2992        assert!(
2993            rendered.contains("<think>I need to compute the square root of 144.</think>"),
2994            "reasoning_content must appear as <think> block, got: {}",
2995            rendered
2996        );
2997        assert!(
2998            rendered.contains("The answer is 12."),
2999            "original content must be preserved"
3000        );
3001        assert!(
3002            !rendered.contains("reasoning_content"),
3003            "raw reasoning_content field should not leak into prompt"
3004        );
3005    }
3006
3007    // Verify a full agentic flow: assistant reasons, calls a tool, gets a
3008    // result, then reasons again before answering. Both reasoning turns must
3009    // survive into the rendered prompt.
3010    #[test]
3011    fn test_reasoning_content_agentic_tool_call_roundtrip_render() {
3012        use super::OAIPromptFormatter;
3013        let formatter = make_test_formatter();
3014
3015        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
3016            "model": "test-model",
3017            "messages": [
3018                {"role": "user", "content": "What is sqrt(144) + sqrt(256)?"},
3019                {
3020                    "role": "assistant",
3021                    "content": null,
3022                    "reasoning_content": "I need to compute both square roots. Let me start with sqrt(144).",
3023                    "tool_calls": [{
3024                        "id": "call_0",
3025                        "type": "function",
3026                        "function": {
3027                            "name": "calculator",
3028                            "arguments": "{\"expr\": \"sqrt(144)\"}"
3029                        }
3030                    }]
3031                },
3032                {
3033                    "role": "tool",
3034                    "tool_call_id": "call_0",
3035                    "content": "12"
3036                },
3037                {
3038                    "role": "assistant",
3039                    "content": "sqrt(144) = 12 and sqrt(256) = 16, so the answer is 28.",
3040                    "reasoning_content": "Got 12 for sqrt(144). Now sqrt(256) = 16. Sum is 28."
3041                },
3042                {"role": "user", "content": "Thanks!"}
3043            ]
3044        }))
3045        .unwrap();
3046
3047        let rendered = formatter.render(&request).unwrap();
3048
3049        // First assistant turn: reasoning with tool call, null content
3050        assert!(
3051            rendered.contains("<think>I need to compute both square roots"),
3052            "first turn reasoning must be in prompt, got: {}",
3053            rendered
3054        );
3055        // Second assistant turn: reasoning with final answer
3056        assert!(
3057            rendered.contains("<think>Got 12 for sqrt(144)"),
3058            "second turn reasoning must be in prompt"
3059        );
3060        assert!(
3061            rendered.contains("the answer is 28"),
3062            "final answer content must be preserved"
3063        );
3064        // No raw reasoning_content in output
3065        assert!(
3066            !rendered.contains("reasoning_content"),
3067            "raw reasoning_content field should not leak into prompt"
3068        );
3069    }
3070
3071    // Template that does NOT reference reasoning_content — injection should happen.
3072    #[test]
3073    fn test_reasoning_injected_when_template_ignores_it() {
3074        use super::OAIPromptFormatter;
3075        let formatter = make_test_formatter();
3076
3077        // Formatter uses a simple template that doesn't reference reasoning_content
3078        assert!(!formatter.default_template_handles_reasoning);
3079        assert!(!formatter.tool_use_template_handles_reasoning);
3080
3081        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
3082            "model": "test-model",
3083            "messages": [
3084                {"role": "user", "content": "Hello"},
3085                {
3086                    "role": "assistant",
3087                    "content": "Hi.",
3088                    "reasoning_content": "The user said hello."
3089                },
3090                {"role": "user", "content": "Bye"}
3091            ]
3092        }))
3093        .unwrap();
3094
3095        let rendered = formatter.render(&request).unwrap();
3096        assert!(
3097            rendered.contains("<think>The user said hello.</think>"),
3098            "injection must happen when template ignores reasoning_content, got: {}",
3099            rendered
3100        );
3101    }
3102
3103    // Template that DOES reference reasoning_content — injection must be skipped.
3104    #[test]
3105    fn test_reasoning_not_injected_when_template_handles_it() {
3106        use super::tokcfg::ChatTemplate;
3107        use super::{ContextMixins, HfTokenizerConfigJsonFormatter, OAIPromptFormatter};
3108
3109        // Template that natively renders reasoning_content (like Nemotron/Qwen3)
3110        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>
3111{%- endif %}{{ message.role }}: {{ message.content }}
3112{%- endfor %}
3113{%- if add_generation_prompt %}assistant:{%- endif %}"#;
3114
3115        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
3116            "chat_template": template
3117        }))
3118        .unwrap();
3119
3120        let formatter =
3121            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
3122
3123        // Verify detection worked
3124        assert!(formatter.default_template_handles_reasoning);
3125        assert!(formatter.tool_use_template_handles_reasoning);
3126
3127        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
3128            "model": "test-model",
3129            "messages": [
3130                {"role": "user", "content": "Hello"},
3131                {
3132                    "role": "assistant",
3133                    "content": "Hi.",
3134                    "reasoning_content": "The user said hello."
3135                },
3136                {"role": "user", "content": "Bye"}
3137            ]
3138        }))
3139        .unwrap();
3140
3141        let rendered = formatter.render(&request).unwrap();
3142
3143        // Template renders reasoning natively — no duplicate injection
3144        assert!(
3145            rendered.contains("<think>The user said hello.</think>"),
3146            "template must render reasoning_content natively, got: {}",
3147            rendered
3148        );
3149        // Must NOT have double <think> blocks
3150        let think_count = rendered.matches("<think>").count();
3151        assert_eq!(
3152            think_count, 1,
3153            "must have exactly one <think> block (from template), got {} in: {}",
3154            think_count, rendered
3155        );
3156    }
3157
3158    /// Real Qwen3-4B-Thinking-2507 chat template (verbatim from
3159    /// `Qwen/Qwen3-4B-Thinking-2507/tokenizer_config.json`). Used to
3160    /// regression-test append-only rendering across multi-step tool use.
3161    const QWEN3_THINKING_TEMPLATE: &str = r##"{%- if tools %}
3162    {{- '<|im_start|>system\n' }}
3163    {%- if messages[0].role == 'system' %}
3164        {{- messages[0].content + '\n\n' }}
3165    {%- endif %}
3166    {{- "# 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>" }}
3167    {%- for tool in tools %}
3168        {{- "\n" }}
3169        {{- tool | tojson }}
3170    {%- endfor %}
3171    {{- "\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" }}
3172{%- else %}
3173    {%- if messages[0].role == 'system' %}
3174        {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
3175    {%- endif %}
3176{%- endif %}
3177{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
3178{%- for message in messages[::-1] %}
3179    {%- set index = (messages|length - 1) - loop.index0 %}
3180    {%- 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>')) %}
3181        {%- set ns.multi_step_tool = false %}
3182        {%- set ns.last_query_index = index %}
3183    {%- endif %}
3184{%- endfor %}
3185{%- for message in messages %}
3186    {%- if message.content is string %}
3187        {%- set content = message.content %}
3188    {%- else %}
3189        {%- set content = '' %}
3190    {%- endif %}
3191    {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
3192        {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
3193    {%- elif message.role == "assistant" %}
3194        {%- set reasoning_content = '' %}
3195        {%- if message.reasoning_content is string %}
3196            {%- set reasoning_content = message.reasoning_content %}
3197        {%- else %}
3198            {%- if '</think>' in content %}
3199                {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
3200                {%- set content = content.split('</think>')[-1].lstrip('\n') %}
3201            {%- endif %}
3202        {%- endif %}
3203        {%- if loop.index0 > ns.last_query_index %}
3204            {%- if loop.last or (not loop.last and reasoning_content) %}
3205                {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
3206            {%- else %}
3207                {{- '<|im_start|>' + message.role + '\n' + content }}
3208            {%- endif %}
3209        {%- else %}
3210            {{- '<|im_start|>' + message.role + '\n' + content }}
3211        {%- endif %}
3212        {%- if message.tool_calls %}
3213            {%- for tool_call in message.tool_calls %}
3214                {%- if (loop.first and content) or (not loop.first) %}
3215                    {{- '\n' }}
3216                {%- endif %}
3217                {%- if tool_call.function %}
3218                    {%- set tool_call = tool_call.function %}
3219                {%- endif %}
3220                {{- '<tool_call>\n{"name": "' }}
3221                {{- tool_call.name }}
3222                {{- '", "arguments": ' }}
3223                {%- if tool_call.arguments is string %}
3224                    {{- tool_call.arguments }}
3225                {%- else %}
3226                    {{- tool_call.arguments | tojson }}
3227                {%- endif %}
3228                {{- '}\n</tool_call>' }}
3229            {%- endfor %}
3230        {%- endif %}
3231        {{- '<|im_end|>\n' }}
3232    {%- elif message.role == "tool" %}
3233        {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
3234            {{- '<|im_start|>user' }}
3235        {%- endif %}
3236        {{- '\n<tool_response>\n' }}
3237        {{- content }}
3238        {{- '\n</tool_response>' }}
3239        {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
3240            {{- '<|im_end|>\n' }}
3241        {%- endif %}
3242    {%- endif %}
3243{%- endfor %}
3244{%- if add_generation_prompt %}
3245    {{- '<|im_start|>assistant\n<think>\n' }}
3246{%- endif %}"##;
3247
3248    fn qwen3_thinking_formatter() -> HfTokenizerConfigJsonFormatter {
3249        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
3250            "chat_template": QWEN3_THINKING_TEMPLATE,
3251        }))
3252        .unwrap();
3253        HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap()
3254    }
3255
3256    #[test]
3257    fn test_qwen3_thinking_template_flags_detected() {
3258        let formatter = qwen3_thinking_formatter();
3259        assert!(
3260            formatter.tool_use_template_handles_reasoning,
3261            "template references reasoning_content directly"
3262        );
3263        // The Qwen3-Thinking template is registered as both `default` and
3264        // `tool_use` (single-string HF chat template), so both flags must fire.
3265        assert!(
3266            formatter.default_template_handles_tool_calls_arguments_string,
3267            "default template branches on `arguments is string`"
3268        );
3269        assert!(
3270            formatter.tool_use_template_handles_tool_calls_arguments_string,
3271            "tool_use template branches on `arguments is string`"
3272        );
3273    }
3274
3275    /// Tool-call loop of unsloth's Qwen3.8 template (unsloth/Qwen3.8-27B): it
3276    /// renders mapping arguments as `<parameter=...>` and uses `arguments is
3277    /// string` only to reject strings.
3278    const QWEN38_REJECTS_STRING_ARGS_TEMPLATE: &str = r##"{%- for message in messages %}
3279    {%- if message.role == "assistant" and message.tool_calls %}
3280        {%- for tool_call in message.tool_calls %}
3281            {%- if tool_call.function %}
3282                {%- set tool_call = tool_call.function %}
3283            {%- endif %}
3284            {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
3285            {%- if tool_call.arguments is mapping %}
3286                {%- for args_name, args_value in tool_call.arguments|items %}
3287                    {{- '<parameter=' + args_name + '>\n' + args_value + '\n</parameter>\n' }}
3288                {%- endfor %}
3289            {%- elif tool_call.arguments is string %}
3290                {%- if tool_call.arguments|trim %}
3291                    {{- raise_exception('Tool call arguments were passed as a JSON string.') }}
3292                {%- endif %}
3293            {%- endif %}
3294            {{- '</function>\n</tool_call>' }}
3295        {%- endfor %}
3296    {%- else %}
3297        {{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>\n' }}
3298    {%- endif %}
3299{%- endfor %}"##;
3300
3301    /// A template that mentions `arguments is string` only to reject strings
3302    /// must still get parsed arguments; otherwise every request with a tool call
3303    /// in its history fails.
3304    #[test]
3305    fn test_template_rejecting_string_arguments_gets_objects() {
3306        let chat_template: ChatTemplate = serde_json::from_value(serde_json::json!({
3307            "chat_template": QWEN38_REJECTS_STRING_ARGS_TEMPLATE,
3308        }))
3309        .unwrap();
3310        let formatter =
3311            HfTokenizerConfigJsonFormatter::new(chat_template, ContextMixins::new(&[])).unwrap();
3312        assert!(!formatter.default_template_handles_tool_calls_arguments_string);
3313        assert!(!formatter.tool_use_template_handles_tool_calls_arguments_string);
3314
3315        let request: NvCreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
3316            "model": "qwen3.8",
3317            "messages": [
3318                {"role": "user", "content": "What's the weather in San Francisco?"},
3319                {"role": "assistant", "content": "", "tool_calls": [{
3320                    "id": "call_sf",
3321                    "type": "function",
3322                    "function": {"name": "get_weather", "arguments": "{\"location\": \"San Francisco\"}"}
3323                }]},
3324                {"role": "tool", "tool_call_id": "call_sf", "content": "Foggy"}
3325            ],
3326        }))
3327        .unwrap();
3328        let rendered = formatter.render(&request).unwrap();
3329        assert!(
3330            rendered.contains("<parameter=location>\nSan Francisco\n</parameter>"),
3331            "{rendered}"
3332        );
3333    }
3334
3335    /// Across a multi-step tool-use turn, the rendered prompt for turn N+1
3336    /// must be a strict prefix-extension of [turn-N prompt + bytes the model
3337    /// emitted on turn N]. Otherwise KV-cache prefix matching falls off a
3338    /// cliff every time a tool result comes back.
3339    ///
3340    /// The Qwen3-Thinking template's `is string` branch (template lines 63-67)
3341    /// renders `tool_call.arguments` verbatim from the OpenAI-canonical JSON
3342    /// string. Pre-parsing that string into an object forces the `else` branch
3343    /// and re-emits with minijinja's compact `tojson`, breaking append-only.
3344    #[test]
3345    fn test_qwen3_thinking_append_only_across_tool_use_turn() {
3346        let formatter = qwen3_thinking_formatter();
3347
3348        let tools = serde_json::json!([{
3349            "type": "function",
3350            "function": {
3351                "name": "get_weather",
3352                "description": "Get the current weather for a location",
3353                "parameters": {
3354                    "type": "object",
3355                    "properties": {
3356                        "location": {"type": "string"},
3357                        "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
3358                    },
3359                    "required": ["location"]
3360                }
3361            }
3362        }]);
3363
3364        // Turn 1: server is asked to produce the first assistant turn.
3365        let turn1_request: NvCreateChatCompletionRequest =
3366            serde_json::from_value(serde_json::json!({
3367                "model": "qwen3-thinking",
3368                "messages": [
3369                    {"role": "system", "content": "You are a helpful assistant."},
3370                    {"role": "user", "content": "What's the weather in San Francisco?"},
3371                ],
3372                "tools": tools,
3373            }))
3374            .unwrap();
3375        let p1 = formatter.render(&turn1_request).unwrap();
3376
3377        // Bytes the model emits next. Spacing matches the Qwen3 training
3378        // distribution (Python jinja2 / json.dumps defaults: `, ` and `: `).
3379        // Empty content + reasoning + a tool call.
3380        let model_emitted = "I'll call get_weather for SF.\n\
3381            </think>\n\n\
3382            <tool_call>\n\
3383            {\"name\": \"get_weather\", \"arguments\": {\"location\": \"San Francisco\", \"unit\": \"celsius\"}}\n\
3384            </tool_call><|im_end|>\n";
3385        let wire_after_t1 = format!("{p1}{model_emitted}");
3386
3387        // Turn 2: client sends the prior assistant turn back in OpenAI canonical
3388        // form (arguments as a JSON STRING with spaces) plus the tool result.
3389        let turn2_request: NvCreateChatCompletionRequest =
3390            serde_json::from_value(serde_json::json!({
3391                "model": "qwen3-thinking",
3392                "messages": [
3393                    {"role": "system", "content": "You are a helpful assistant."},
3394                    {"role": "user", "content": "What's the weather in San Francisco?"},
3395                    {
3396                        "role": "assistant",
3397                        "content": "",
3398                        "reasoning_content": "I'll call get_weather for SF.",
3399                        "tool_calls": [{
3400                            "id": "call_sf",
3401                            "type": "function",
3402                            "function": {
3403                                "name": "get_weather",
3404                                "arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
3405                            }
3406                        }]
3407                    },
3408                    {
3409                        "role": "tool",
3410                        "tool_call_id": "call_sf",
3411                        "content": "{\"temp\": 18, \"conditions\": \"Foggy\"}"
3412                    }
3413                ],
3414                "tools": tools,
3415            }))
3416            .unwrap();
3417        let p2 = formatter.render(&turn2_request).unwrap();
3418
3419        if !p2.starts_with(&wire_after_t1) {
3420            // Find first divergence and report it for easy debugging.
3421            let div = wire_after_t1
3422                .as_bytes()
3423                .iter()
3424                .zip(p2.as_bytes())
3425                .position(|(a, b)| a != b)
3426                .unwrap_or_else(|| wire_after_t1.len().min(p2.len()));
3427            let lo = div.saturating_sub(40);
3428            panic!(
3429                "turn-2 prompt is NOT a prefix-extension of [turn-1 + model bytes]\n  \
3430                 diverges at byte {div}\n  \
3431                 wire ends: ...{}|{}\n  \
3432                 t2 has:    ...{}|{}",
3433                String::from_utf8_lossy(&wire_after_t1.as_bytes()[lo..div]),
3434                String::from_utf8_lossy(
3435                    &wire_after_t1.as_bytes()[div..(div + 60).min(wire_after_t1.len())]
3436                ),
3437                String::from_utf8_lossy(&p2.as_bytes()[lo..div]),
3438                String::from_utf8_lossy(&p2.as_bytes()[div..(div + 60).min(p2.len())]),
3439            );
3440        }
3441
3442        // The only new bytes in P2 should be the tool response and the next
3443        // generation prompt — nothing in the prior conversation should change.
3444        let suffix = &p2[wire_after_t1.len()..];
3445        assert!(
3446            suffix.contains("<tool_response>"),
3447            "appended bytes must include the tool response, got: {suffix}"
3448        );
3449        assert!(
3450            suffix.ends_with("<|im_start|>assistant\n<think>\n"),
3451            "appended bytes must end with the next generation prompt, got: {suffix}"
3452        );
3453    }
3454}