Skip to main content

dynamo_renderer/template/
oai.rs

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