Skip to main content

dynamo_renderer/
kimi_k3.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native Kimi K3 XTML prompt rendering.
5//!
6//! K3 does not ship a Jinja chat template. Its model-side `encoding_k3.py`
7//! emits a sequence of segments where protocol markers are encoded with
8//! tiktoken special IDs and message/tool data is encoded as ordinary text.
9//! Keeping that distinction is required both for model parity and to prevent a
10//! literal marker in user content from becoming prompt structure.
11
12use std::collections::HashMap;
13
14use anyhow::{Context, Result, bail};
15use serde_json::{Map, Value};
16
17use crate::{
18    OAIChatLikeRequest, OAIPromptFormatter, PromptRenderError, RenderedPrompt, RenderedSegment,
19    thinking_bool_from_args,
20};
21
22const OPEN_TOKEN: &str = "<|open|>";
23const CLOSE_TOKEN: &str = "<|close|>";
24const SEP_TOKEN: &str = "<|sep|>";
25const END_OF_MSG_TOKEN: &str = "<|end_of_msg|>";
26/// The one token this renderer emits per image.
27///
28/// This is the canonical frontend contract: exactly one `<|media_pad|>` per
29/// image, for every engine. It is a registered special token in the K3
30/// tokenizer (`config.json`'s `media_placeholder_token_id`), so it encodes to
31/// a single id and stays one id no matter what surrounds it.
32///
33/// The checkpoint's other spelling, `<|kimi_image_placeholder|>`, is a plain
34/// string that is *not* in the vocabulary — it BPE-shatters into several ids
35/// whose boundaries depend on neighbouring text. Engines that want that form
36/// (vLLM) convert from the pad on the worker side, where a single known id is
37/// a reliable thing to substitute; matching a shattered string is not.
38///
39/// Equivalent to calling the checkpoint's own
40/// `encoding_k3.build_chat_segments(image_prompts=["<|media_pad|>"] * n)` —
41/// `image_prompts` is the model author's hook for exactly this choice, and
42/// `<|kimi_image_placeholder|>` is only its `None` fallback.
43const MEDIA_PAD: &str = "<|media_pad|>";
44const VALID_THINKING_EFFORTS: &[&str] = &["low", "high", "max"];
45
46#[derive(Debug, Clone)]
47pub struct KimiK3Formatter {
48    exclude_tools_when_tool_choice_none: bool,
49}
50
51impl KimiK3Formatter {
52    pub fn new(exclude_tools_when_tool_choice_none: bool) -> Self {
53        Self {
54            exclude_tools_when_tool_choice_none,
55        }
56    }
57
58    fn build_segments(&self, req: &dyn OAIChatLikeRequest) -> Result<Vec<RenderedSegment>> {
59        let messages = json_value(req.messages()).context("Failed to convert K3 messages")?;
60        let messages = messages
61            .as_array()
62            .context("Kimi K3 messages must be an array")?;
63        let messages = normalize_tool_result_messages(messages)?;
64
65        let tool_choice = req.tool_choice().map(json_value).transpose()?;
66        let (tool_choice_kind, named_tool) = resolve_tool_choice(tool_choice.as_ref())?;
67        let mut tools = req.tools().map(json_value).transpose()?;
68        // A named tool_choice may target a message-level declaration that
69        // never appears in the top-level list.
70        if let Some(named_tool) = named_tool
71            && !tools
72                .as_ref()
73                .is_some_and(|tools| contains_tool(tools, named_tool))
74            && !messages
75                .iter()
76                .any(|message| message_declares_tool(message, named_tool))
77        {
78            return Err(PromptRenderError::invalid_request(format!(
79                "tool named {named_tool:?} in tool_choice is not present in tools"
80            ))
81            .into());
82        }
83        if self.exclude_tools_when_tool_choice_none && tool_choice_kind == Some("none") {
84            tools = None;
85        }
86        let tools = tools.map(deep_sort);
87
88        let args = req.chat_template_args();
89        // Moonshot's K3 API defines named tool choice as incompatible with
90        // thinking. Make the public function-object form work without requiring
91        // clients to know K3-specific chat-template arguments.
92        let thinking = named_tool.is_none() && thinking_bool_from_args(args).unwrap_or(true);
93        let thinking_effort = resolve_thinking_effort(args);
94        if thinking && !VALID_THINKING_EFFORTS.contains(&thinking_effort.as_str()) {
95            return Err(PromptRenderError::invalid_request(format!(
96                "Unsupported Kimi K3 thinking_effort={thinking_effort:?}; supported values are low, high, and max"
97            ))
98            .into());
99        }
100
101        let response_format = req.response_format().map(json_value).transpose()?;
102        build_chat_segments(
103            &messages,
104            tools.as_ref(),
105            tool_choice_kind,
106            named_tool,
107            response_format.as_ref(),
108            req.should_add_generation_prompt(),
109            thinking,
110            thinking_effort.as_str(),
111        )
112    }
113}
114
115impl OAIPromptFormatter for KimiK3Formatter {
116    fn supports_add_generation_prompt(&self) -> bool {
117        true
118    }
119
120    fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
121        Ok(RenderedPrompt::segmented(self.build_segments(req)?).into_text())
122    }
123
124    fn render_prompt(&self, req: &dyn OAIChatLikeRequest) -> Result<RenderedPrompt> {
125        Ok(RenderedPrompt::segmented(self.build_segments(req)?))
126    }
127}
128
129fn json_value(value: minijinja::value::Value) -> Result<Value> {
130    serde_json::to_value(&value).context("Failed to convert template value to JSON")
131}
132
133fn resolve_tool_choice(tool_choice: Option<&Value>) -> Result<(Option<&str>, Option<&str>)> {
134    match tool_choice {
135        Some(Value::String(kind)) => Ok((Some(kind.as_str()), None)),
136        Some(Value::Object(choice)) => {
137            if choice.get("type").and_then(Value::as_str) != Some("function") {
138                return Err(PromptRenderError::invalid_request(
139                    "Kimi K3 named tool_choice must have type=\"function\"",
140                )
141                .into());
142            }
143            // Chat Completions uses function.name. Responses API uses a
144            // top-level name and is normalized to the same internal request in
145            // Dynamo, but accepting both shapes keeps this renderer reusable.
146            let name = choice
147                .get("function")
148                .and_then(Value::as_object)
149                .and_then(|function| function.get("name"))
150                .or_else(|| choice.get("name"))
151                .and_then(Value::as_str)
152                .filter(|name| !name.is_empty())
153                .ok_or_else(|| {
154                    PromptRenderError::invalid_request(
155                        "Kimi K3 named tool_choice requires a non-empty function name",
156                    )
157                })?;
158            Ok((Some("specified"), Some(name)))
159        }
160        Some(Value::Null) | None => Ok((None, None)),
161        Some(other) => Err(anyhow::anyhow!(
162            "Unsupported Kimi K3 tool_choice value: {other}"
163        )),
164    }
165}
166
167fn contains_tool(tools: &Value, name: &str) -> bool {
168    tools.as_array().is_some_and(|tools| {
169        tools.iter().any(|tool| {
170            tool.get("function")
171                .and_then(Value::as_object)
172                .and_then(|function| function.get("name"))
173                .or_else(|| tool.get("name"))
174                .and_then(Value::as_str)
175                == Some(name)
176        })
177    })
178}
179
180/// Longest tool name Moonshot's vendor verifier accepts.
181const MAX_TOOL_NAME_LEN: usize = 256;
182
183/// The dynamic tool declaration carried by a system or developer message.
184///
185/// `Ok(Some(..))` for a non-empty `tools` array; `Ok(None)` when `tools` is
186/// missing, `null`, or an empty array (an empty list declares nothing, so the
187/// message is an ordinary turn); `Err` for any other JSON type.
188fn dynamic_tools_of(message: &Value) -> Result<Option<&Vec<Value>>> {
189    match message.get("tools") {
190        None | Some(Value::Null) => Ok(None),
191        Some(Value::Array(tools)) if tools.is_empty() => Ok(None),
192        Some(Value::Array(tools)) => Ok(Some(tools)),
193        Some(_) => Err(PromptRenderError::invalid_request(
194            "Kimi K3 dynamic tool messages need `tools` to be an array",
195        )
196        .into()),
197    }
198}
199
200/// Accepts both OpenAI-wrapped and bare Kimi tool declarations; rejects mixed
201/// shapes so every entry has one unambiguous name.
202fn dynamic_tool_entry_name(tool: &Value) -> Result<&str> {
203    let object = tool.as_object().ok_or_else(|| {
204        PromptRenderError::invalid_request("Kimi K3 dynamic tool entries must be JSON objects")
205    })?;
206    let name = match (object.get("type"), object.get("function")) {
207        (Some(kind), function) => {
208            if kind.as_str() != Some("function") {
209                return Err(PromptRenderError::invalid_request(format!(
210                    "Kimi K3 dynamic tool entries must have type=\"function\", got {kind}"
211                ))
212                .into());
213            }
214            let function = function.and_then(Value::as_object).ok_or_else(|| {
215                PromptRenderError::invalid_request(
216                    "Kimi K3 dynamic tool entries with type=\"function\" need a `function` object",
217                )
218            })?;
219            function.get("name")
220        }
221        (None, Some(_)) => {
222            return Err(PromptRenderError::invalid_request(
223                "Kimi K3 dynamic tool entries with a `function` object need type=\"function\"",
224            )
225            .into());
226        }
227        (None, None) => object.get("name"),
228    };
229    let name = name.and_then(Value::as_str).ok_or_else(|| {
230        PromptRenderError::invalid_request("Kimi K3 dynamic tool entries need a string `name`")
231    })?;
232    validate_tool_name(name)?;
233    Ok(name)
234}
235
236/// Tool names must match `[A-Za-z_][A-Za-z0-9_-]*` and be at most
237/// [`MAX_TOOL_NAME_LEN`] characters (Moonshot vendor verifier rules).
238fn validate_tool_name(name: &str) -> Result<()> {
239    let mut chars = name.chars();
240    let valid_start = chars
241        .next()
242        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
243    let valid_rest = chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
244    if !valid_start || !valid_rest {
245        return Err(PromptRenderError::invalid_request(format!(
246            "Kimi K3 tool name {name:?} must match [A-Za-z_][A-Za-z0-9_-]*"
247        ))
248        .into());
249    }
250    if name.len() > MAX_TOOL_NAME_LEN {
251        return Err(PromptRenderError::invalid_request(format!(
252            "Kimi K3 tool name is {} characters; the maximum is {MAX_TOOL_NAME_LEN}",
253            name.len()
254        ))
255        .into());
256    }
257    Ok(())
258}
259
260/// Validate top-level and message-level tools as one namespace: entries must
261/// be well-formed and names unique. Raw requests retain the renderer's native
262/// developer-tool support alongside Kimi's system-tool declarations.
263fn validate_tool_declarations(top_level: Option<&Value>, messages: &[Value]) -> Result<()> {
264    let mut seen = std::collections::HashSet::new();
265    // The OpenAI schema does not enforce Kimi's tool-name rules.
266    for tool in top_level.and_then(Value::as_array).into_iter().flatten() {
267        let name = dynamic_tool_entry_name(tool)?;
268        if !seen.insert(name) {
269            return Err(PromptRenderError::invalid_request(format!(
270                "tool {name:?} is declared more than once in `tools`"
271            ))
272            .into());
273        }
274    }
275    for message in messages {
276        let role = message.get("role").and_then(Value::as_str);
277        if !matches!(role, Some("system" | "developer")) {
278            if message.get("tools").is_some_and(|tools| !tools.is_null()) {
279                return Err(PromptRenderError::invalid_request(format!(
280                    "`tools` is only accepted on system or developer messages, not on role {}",
281                    role.unwrap_or("<missing>")
282                ))
283                .into());
284            }
285            continue;
286        }
287        for tool in dynamic_tools_of(message)?.into_iter().flatten() {
288            let name = dynamic_tool_entry_name(tool)?;
289            if !seen.insert(name) {
290                return Err(PromptRenderError::invalid_request(format!(
291                    "tool {name:?} is declared more than once across `tools` and dynamic message tools"
292                ))
293                .into());
294            }
295        }
296    }
297    Ok(())
298}
299
300/// Whether `content` carries text: missing, `null`, `""`, and `[]` all count
301/// as empty, matching Moonshot's "omit `content`" contract for dynamic tools.
302fn content_is_non_empty(content: Option<&Value>) -> bool {
303    match content {
304        None | Some(Value::Null) => false,
305        Some(Value::String(text)) => !text.is_empty(),
306        Some(Value::Array(parts)) => !parts.is_empty(),
307        Some(_) => true,
308    }
309}
310
311fn message_declares_tool(message: &Value, name: &str) -> bool {
312    matches!(
313        message.get("role").and_then(Value::as_str),
314        Some("system" | "developer")
315    ) && message
316        .get("tools")
317        .is_some_and(|tools| contains_tool(tools, name))
318}
319
320fn resolve_thinking_effort(args: Option<&HashMap<String, Value>>) -> String {
321    args.and_then(|args| {
322        args.get("thinking_effort")
323            .or_else(|| args.get("reasoning_effort"))
324            .and_then(Value::as_str)
325    })
326    .unwrap_or("max")
327    .to_string()
328}
329
330fn push_segment(segments: &mut Vec<RenderedSegment>, text: impl Into<String>, allow_special: bool) {
331    let text = text.into();
332    if !text.is_empty() {
333        segments.push(RenderedSegment {
334            text,
335            allow_special,
336        });
337    }
338}
339
340fn control(segments: &mut Vec<RenderedSegment>, text: impl Into<String>) {
341    push_segment(segments, text, true);
342}
343
344fn text(segments: &mut Vec<RenderedSegment>, text: impl Into<String>) {
345    push_segment(segments, text, false);
346}
347
348fn escape_attr_value(value: impl std::fmt::Display) -> String {
349    value
350        .to_string()
351        .replace('&', "&amp;")
352        .replace('"', "&quot;")
353}
354
355fn open_tag(
356    segments: &mut Vec<RenderedSegment>,
357    tag: &str,
358    attrs: impl IntoIterator<Item = (String, String)>,
359) {
360    control(segments, OPEN_TOKEN);
361    text(segments, tag);
362    for (key, value) in attrs {
363        text(segments, format!(" {key}"));
364        text(segments, "=\"");
365        text(segments, escape_attr_value(value));
366        text(segments, "\"");
367    }
368    control(segments, SEP_TOKEN);
369}
370
371fn close_tag(segments: &mut Vec<RenderedSegment>, tag: &str) {
372    control(segments, CLOSE_TOKEN);
373    text(segments, tag);
374    control(segments, SEP_TOKEN);
375}
376
377fn end_of_msg(segments: &mut Vec<RenderedSegment>) {
378    control(segments, END_OF_MSG_TOKEN);
379}
380
381fn internal_system_message(segments: &mut Vec<RenderedSegment>, message_type: &str, body: &str) {
382    open_tag(
383        segments,
384        "message",
385        [
386            ("role".to_string(), "system".to_string()),
387            ("type".to_string(), message_type.to_string()),
388        ],
389    );
390    text(segments, body.trim());
391    close_tag(segments, "message");
392    end_of_msg(segments);
393}
394
395fn deep_sort(value: Value) -> Value {
396    match value {
397        Value::Object(map) => {
398            let mut entries: Vec<_> = map.into_iter().collect();
399            entries.sort_by(|(left, _), (right, _)| left.cmp(right));
400            Value::Object(
401                entries
402                    .into_iter()
403                    .map(|(key, value)| (key, deep_sort(value)))
404                    .collect(),
405            )
406        }
407        Value::Array(items) => Value::Array(items.into_iter().map(deep_sort).collect()),
408        other => other,
409    }
410}
411
412fn compact_json(value: &Value) -> Result<String> {
413    serde_json::to_string(value).context("Failed to serialize K3 JSON")
414}
415
416fn response_schema(response_format: &Value) -> Option<Value> {
417    let json_schema = response_format.get("json_schema")?;
418    if let Some(schema) = json_schema.get("schema") {
419        return Some(schema.clone());
420    }
421    if let Some(schema) = json_schema.get("json_schema") {
422        return Some(schema.clone());
423    }
424    Some(json_schema.clone())
425}
426
427fn value_as_body_text(value: &Value) -> Result<String> {
428    match value {
429        Value::String(value) => Ok(value.clone()),
430        Value::Array(values) if values.iter().all(Value::is_string) => Ok(values
431            .iter()
432            .filter_map(Value::as_str)
433            .filter(|value| !value.is_empty())
434            .collect::<Vec<_>>()
435            .join("\n")),
436        other => compact_json(other),
437    }
438}
439
440fn render_content_segments(
441    segments: &mut Vec<RenderedSegment>,
442    content: Option<&Value>,
443) -> Result<()> {
444    let Some(content) = content else {
445        return Ok(());
446    };
447    match content {
448        Value::Null => {}
449        Value::String(value) => text(segments, value),
450        Value::Array(parts) => {
451            for part in parts {
452                match part.get("type").and_then(Value::as_str) {
453                    Some("image" | "image_url") => control(segments, MEDIA_PAD),
454                    _ => {
455                        if let Some(part_text) = part.get("text") {
456                            text(segments, value_as_body_text(part_text)?);
457                        }
458                    }
459                }
460            }
461        }
462        other => text(segments, value_as_body_text(other)?),
463    }
464    Ok(())
465}
466
467fn render_role_message(
468    segments: &mut Vec<RenderedSegment>,
469    message: &Value,
470    role: &str,
471) -> Result<()> {
472    let mut attrs = vec![("role".to_string(), role.to_string())];
473    if let Some(name) = message
474        .get("name")
475        .and_then(Value::as_str)
476        .filter(|name| !name.is_empty())
477    {
478        attrs.push(("name".to_string(), name.to_string()));
479    }
480    open_tag(segments, "message", attrs);
481    render_content_segments(segments, message.get("content"))?;
482    close_tag(segments, "message");
483    end_of_msg(segments);
484    Ok(())
485}
486
487fn render_tool_declare(
488    segments: &mut Vec<RenderedSegment>,
489    tools: &Value,
490    dynamic: bool,
491) -> Result<()> {
492    let tools = compact_json(tools)?;
493    let body = if dynamic {
494        format!(
495            "## New Tools Available\n\
496             The system dynamically extends the toolset via lazy-loading.\n\
497             You have access to all existing and extended tools.\n\
498             Here are the specs for the extended tools.\n\n\
499             ```json\n{tools}\n```"
500        )
501    } else {
502        format!(
503            "# Tools\n\
504             Here are the available tools, described in JSONSchema.\n\n\
505             ```json\n{tools}\n```"
506        )
507    };
508    open_tag(
509        segments,
510        "message",
511        [
512            ("role".to_string(), "system".to_string()),
513            ("type".to_string(), "tool-declare".to_string()),
514        ],
515    );
516    text(segments, body);
517    close_tag(segments, "message");
518    end_of_msg(segments);
519    Ok(())
520}
521
522fn xtml_type(value: &Value) -> &'static str {
523    match value {
524        Value::Bool(_) => "boolean",
525        Value::Null => "null",
526        Value::Number(_) => "number",
527        Value::String(_) => "string",
528        Value::Object(_) => "object",
529        Value::Array(_) => "array",
530    }
531}
532
533fn xtml_value(value: &Value) -> Result<String> {
534    match value {
535        Value::String(value) => Ok(value.clone()),
536        // Python's `json.dumps(..., ensure_ascii=False)` uses `", "` and
537        // `": "` separators by default. Preserve that byte shape in prompt
538        // history; the compact form is used only for schemas/tool declarations.
539        other => python_default_json(other),
540    }
541}
542
543fn python_default_json(value: &Value) -> Result<String> {
544    let compact = compact_json(value)?;
545    let mut output = String::with_capacity(compact.len());
546    let mut in_string = false;
547    let mut escaped = false;
548    for ch in compact.chars() {
549        output.push(ch);
550        if in_string {
551            if escaped {
552                escaped = false;
553            } else if ch == '\\' {
554                escaped = true;
555            } else if ch == '"' {
556                in_string = false;
557            }
558        } else if ch == '"' {
559            in_string = true;
560        } else if matches!(ch, ',' | ':') {
561            output.push(' ');
562        }
563    }
564    Ok(output)
565}
566
567enum NormalizedArguments {
568    Object(Map<String, Value>),
569    JsonBlock(String),
570}
571
572fn normalize_arguments(arguments: Option<&Value>) -> Result<NormalizedArguments> {
573    let Some(arguments) = arguments else {
574        return Ok(NormalizedArguments::Object(Map::new()));
575    };
576    match arguments {
577        Value::Null => Ok(NormalizedArguments::Object(Map::new())),
578        Value::Object(arguments) => Ok(NormalizedArguments::Object(arguments.clone())),
579        Value::String(arguments) if arguments.trim().is_empty() => {
580            Ok(NormalizedArguments::Object(Map::new()))
581        }
582        Value::String(arguments) => match serde_json::from_str::<Value>(arguments) {
583            Ok(Value::Object(arguments)) => Ok(NormalizedArguments::Object(arguments)),
584            Ok(_) => bail!("Kimi K3 tool call arguments must be a JSON object"),
585            Err(_) => Ok(NormalizedArguments::JsonBlock(arguments.clone())),
586        },
587        _ => bail!("Kimi K3 tool call arguments must be an object or JSON object string"),
588    }
589}
590
591/// Renders an assistant message's think channel.
592///
593/// The think channel is structural in the latest K3 model encoding. Every
594/// historical assistant message carries it in thinking mode, even if its body
595/// is empty. Non-thinking mode drops both the channel and preserved reasoning
596/// content.
597fn render_think_channel(
598    segments: &mut Vec<RenderedSegment>,
599    message: &Value,
600    thinking: bool,
601) -> Result<()> {
602    if !thinking {
603        return Ok(());
604    }
605    // Match encoding_k3.py: `reasoning_content` wins when truthy, otherwise
606    // fall back to the Responses-style `reasoning` alias.
607    let reasoning = message
608        .get("reasoning_content")
609        .filter(|value| match value {
610            Value::Null => false,
611            Value::Bool(value) => *value,
612            Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
613            Value::String(value) => !value.is_empty(),
614            Value::Array(value) => !value.is_empty(),
615            Value::Object(value) => !value.is_empty(),
616        })
617        .or_else(|| message.get("reasoning"))
618        .map(value_as_body_text)
619        .transpose()?;
620
621    open_tag(segments, "think", []);
622    if let Some(reasoning) = reasoning.filter(|reasoning| !reasoning.trim().is_empty()) {
623        text(segments, reasoning);
624    }
625    close_tag(segments, "think");
626    Ok(())
627}
628
629fn assistant_message_attrs(message: &Value) -> Vec<(String, String)> {
630    let mut attrs = vec![("role".to_string(), "assistant".to_string())];
631    if let Some(name) = message
632        .get("name")
633        .and_then(Value::as_str)
634        .filter(|name| !name.is_empty())
635    {
636        attrs.push(("name".to_string(), name.to_string()));
637    }
638    attrs
639}
640
641fn is_partial(message: &Value) -> bool {
642    message.get("partial").and_then(Value::as_bool) == Some(true)
643}
644
645/// Leaves the assistant response and message open for prefix continuation,
646/// replacing the ordinary generation prompt. In thinking mode, the think
647/// channel is rendered and closed before the response opens.
648fn render_partial_assistant_segments(
649    segments: &mut Vec<RenderedSegment>,
650    message: &Value,
651    thinking: bool,
652) -> Result<()> {
653    if message
654        .get("tool_calls")
655        .is_some_and(|calls| !calls.is_null() && !calls.as_array().is_some_and(Vec::is_empty))
656    {
657        return Err(PromptRenderError::invalid_request(
658            "Kimi K3 partial assistant messages cannot carry tool_calls",
659        )
660        .into());
661    }
662    open_tag(segments, "message", assistant_message_attrs(message));
663    render_think_channel(segments, message, thinking)?;
664    open_tag(segments, "response", []);
665    render_content_segments(segments, message.get("content"))?;
666    Ok(())
667}
668
669fn render_assistant_segments(
670    segments: &mut Vec<RenderedSegment>,
671    message: &Value,
672    thinking: bool,
673) -> Result<()> {
674    render_think_channel(segments, message, thinking)?;
675
676    open_tag(segments, "response", []);
677    render_content_segments(segments, message.get("content"))?;
678    close_tag(segments, "response");
679
680    let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else {
681        return Ok(());
682    };
683    if tool_calls.is_empty() {
684        return Ok(());
685    }
686
687    open_tag(segments, "tools", []);
688    for (position, tool_call) in tool_calls.iter().enumerate() {
689        let function = tool_call.get("function").unwrap_or(tool_call);
690        let name = function
691            .get("name")
692            .and_then(Value::as_str)
693            .context("Kimi K3 tool call is missing function.name")?;
694        open_tag(
695            segments,
696            "call",
697            [
698                ("tool".to_string(), name.to_string()),
699                ("index".to_string(), (position + 1).to_string()),
700            ],
701        );
702
703        match normalize_arguments(function.get("arguments"))? {
704            NormalizedArguments::JsonBlock(raw) => {
705                open_tag(
706                    segments,
707                    "json",
708                    [("type".to_string(), "object".to_string())],
709                );
710                text(segments, raw);
711                close_tag(segments, "json");
712            }
713            NormalizedArguments::Object(arguments) => {
714                for (key, value) in arguments {
715                    open_tag(
716                        segments,
717                        "argument",
718                        [
719                            ("key".to_string(), key),
720                            ("type".to_string(), xtml_type(&value).to_string()),
721                        ],
722                    );
723                    text(segments, xtml_value(&value)?);
724                    close_tag(segments, "argument");
725                }
726            }
727        }
728        close_tag(segments, "call");
729    }
730    close_tag(segments, "tools");
731    Ok(())
732}
733
734fn tool_call_index(tool_calls: Option<&Value>) -> HashMap<String, (usize, Option<String>)> {
735    let mut index = HashMap::new();
736    let Some(tool_calls) = tool_calls.and_then(Value::as_array) else {
737        return index;
738    };
739    for (position, tool_call) in tool_calls.iter().enumerate() {
740        let Some(id) = tool_call.get("id").and_then(Value::as_str) else {
741            continue;
742        };
743        let function = tool_call.get("function").unwrap_or(tool_call);
744        let name = function
745            .get("name")
746            .and_then(Value::as_str)
747            .map(str::to_string);
748        index.entry(id.to_string()).or_insert((position + 1, name));
749    }
750    index
751}
752
753fn normalize_tool_result_messages(messages: &[Value]) -> Result<Vec<Value>> {
754    let mut output = Vec::with_capacity(messages.len());
755    let mut current_index = HashMap::new();
756    let mut position = 0;
757
758    while position < messages.len() {
759        let message = &messages[position];
760        let role = message.get("role").and_then(Value::as_str);
761        if role == Some("assistant") {
762            current_index = tool_call_index(message.get("tool_calls"));
763            output.push(message.clone());
764            position += 1;
765            continue;
766        }
767        if role != Some("tool") {
768            output.push(message.clone());
769            position += 1;
770            continue;
771        }
772
773        let mut run: Vec<(Option<usize>, usize, Value, Option<String>)> = Vec::new();
774        let mut unresolved = false;
775        let mut offset = 0;
776        while position < messages.len()
777            && messages[position].get("role").and_then(Value::as_str) == Some("tool")
778        {
779            let tool_message = &messages[position];
780            let call_id = tool_message
781                .get("tool_call_id")
782                .or_else(|| tool_message.get("id"))
783                .and_then(Value::as_str);
784            let matched = call_id.and_then(|id| current_index.get(id));
785            if let Some((tool_position, name)) = matched {
786                run.push((
787                    Some(*tool_position),
788                    offset,
789                    tool_message.clone(),
790                    name.clone(),
791                ));
792            } else {
793                unresolved = true;
794                run.push((None, offset, tool_message.clone(), None));
795            }
796            offset += 1;
797            position += 1;
798        }
799
800        if unresolved {
801            output.extend(run.into_iter().map(|(_, _, message, _)| message));
802            continue;
803        }
804        run.sort_by_key(|(tool_position, offset, _, _)| (*tool_position, *offset));
805        for (_, _, mut message, name) in run {
806            if let (Some(name), Some(message)) = (name, message.as_object_mut()) {
807                message.insert("tool".to_string(), Value::String(name.clone()));
808                if message.contains_key("name") {
809                    message.insert("name".to_string(), Value::String(name));
810                }
811            }
812            output.push(message);
813        }
814    }
815    Ok(output)
816}
817
818#[allow(clippy::too_many_arguments)]
819fn build_chat_segments(
820    messages: &[Value],
821    tools: Option<&Value>,
822    tool_choice: Option<&str>,
823    named_tool: Option<&str>,
824    response_format: Option<&Value>,
825    add_generation_prompt: bool,
826    thinking: bool,
827    thinking_effort: &str,
828) -> Result<Vec<RenderedSegment>> {
829    let mut segments = Vec::new();
830    let mut previous_tool_calls: Option<&Value> = None;
831    let mut tool_index = 0usize;
832
833    for message in messages {
834        let Some(partial) = message.get("partial").filter(|value| !value.is_null()) else {
835            continue;
836        };
837        if message.get("role").and_then(Value::as_str) != Some("assistant") {
838            return Err(PromptRenderError::invalid_request(
839                "Kimi K3 `partial` is only supported on an assistant message",
840            )
841            .into());
842        }
843        if !partial.is_boolean() {
844            return Err(
845                PromptRenderError::invalid_request("Kimi K3 `partial` must be a boolean").into(),
846            );
847        }
848    }
849
850    // Kimi Partial Mode: only the final message may be partial, and it must be
851    // an assistant turn. Split it off so the history loop renders everything
852    // before it normally and the partial turn takes the generation prompt's
853    // place at the very end (after any internal system messages).
854    let (history, partial_tail) = match messages.split_last() {
855        Some((last, history)) if is_partial(last) => (history, Some(last)),
856        _ => (messages, None),
857    };
858
859    // Validate the complete raw message list, including a split-off Partial
860    // Mode tail. Otherwise `tools` on the final partial assistant message
861    // bypasses the supported-role check below.
862    validate_tool_declarations(tools, messages)?;
863    if history.iter().any(is_partial) {
864        return Err(PromptRenderError::invalid_request(
865            "Kimi K3 `partial` is only supported on the final message",
866        )
867        .into());
868    }
869
870    if let Some(tools) = tools.filter(|tools| !tools.as_array().is_some_and(Vec::is_empty)) {
871        render_tool_declare(&mut segments, tools, false)?;
872    }
873
874    if thinking {
875        internal_system_message(
876            &mut segments,
877            "thinking-effort",
878            &format!(
879                "`thinking_effort` guides on how much to think in your thinking channel \
880                 (not including the response channel), supported values include `low`, \
881                 `medium`, `high`, and `max`.\nNow the system is invoked with \
882                 `thinking_effort={thinking_effort}`."
883            ),
884        );
885    }
886
887    for message in history {
888        let role = message.get("role").and_then(Value::as_str).ok_or_else(|| {
889            PromptRenderError::invalid_request("Kimi K3 messages must contain a string role")
890        })?;
891        // An empty `tools` list is not a dynamic-tool declaration.
892        let dynamic_tools = dynamic_tools_of(message)?;
893        match role {
894            "system" | "developer" if dynamic_tools.is_some() => {
895                let dynamic_tools = dynamic_tools.expect("guarded by the match arm");
896                // Moonshot's contract: a dynamic-tool system message omits
897                // `content` (an empty string counts as omitted; the official
898                // verifier sends `"content": ""`). Rejecting non-empty text
899                // keeps it from being silently lost.
900                if role == "system" && content_is_non_empty(message.get("content")) {
901                    return Err(PromptRenderError::invalid_request(
902                        "Kimi K3 system messages carry either `content` or `tools`, not both",
903                    )
904                    .into());
905                }
906                let dynamic_tools = deep_sort(Value::Array(dynamic_tools.clone()));
907                render_tool_declare(&mut segments, &dynamic_tools, true)?;
908                if role == "developer"
909                    && message
910                        .get("content")
911                        .is_some_and(|content| !content.is_null())
912                {
913                    render_role_message(&mut segments, message, "system")?;
914                }
915            }
916            "system" | "developer"
917                if message
918                    .get("content")
919                    .is_none_or(|content| content.is_null()) =>
920            {
921                return Err(PromptRenderError::invalid_request(format!(
922                    "Kimi K3 {role} messages need `content` or `tools`"
923                ))
924                .into());
925            }
926            "user" | "system" | "developer" => {
927                let rendered_role = if role == "developer" { "system" } else { role };
928                render_role_message(&mut segments, message, rendered_role)?;
929            }
930            "assistant" => {
931                previous_tool_calls = message.get("tool_calls");
932                tool_index = 0;
933                open_tag(&mut segments, "message", assistant_message_attrs(message));
934                render_assistant_segments(&mut segments, message, thinking)?;
935                close_tag(&mut segments, "message");
936                end_of_msg(&mut segments);
937            }
938            "tool" => {
939                tool_index += 1;
940                let fallback_name = previous_tool_calls
941                    .and_then(Value::as_array)
942                    .and_then(|calls| calls.get(tool_index - 1))
943                    .map(|call| call.get("function").unwrap_or(call))
944                    .and_then(|function| function.get("name"))
945                    .and_then(Value::as_str);
946                let tool_name = message
947                    .get("tool")
948                    .or_else(|| message.get("name"))
949                    .and_then(Value::as_str)
950                    .or(fallback_name)
951                    .context(
952                        "Kimi K3 tool messages need a tool/name or a preceding assistant tool call",
953                    )?;
954                open_tag(
955                    &mut segments,
956                    "message",
957                    [
958                        ("role".to_string(), "tool".to_string()),
959                        ("tool".to_string(), tool_name.to_string()),
960                        ("index".to_string(), tool_index.to_string()),
961                    ],
962                );
963                render_content_segments(&mut segments, message.get("content"))?;
964                close_tag(&mut segments, "message");
965                end_of_msg(&mut segments);
966            }
967            unsupported => {
968                return Err(PromptRenderError::invalid_request(format!(
969                    "Kimi K3 does not support message role {unsupported:?}"
970                ))
971                .into());
972            }
973        }
974    }
975
976    match tool_choice {
977        Some("required") => internal_system_message(
978            &mut segments,
979            "tool-choice",
980            "The system is invoked with `tool_choice=required`.\n\
981             You MUST call tools in the next message.",
982        ),
983        Some("none") => internal_system_message(
984            &mut segments,
985            "tool-choice",
986            "The system is invoked with `tool_choice=none`.\n\
987             You MUST NOT call any tools in the next message.",
988        ),
989        Some("specified") => internal_system_message(
990            &mut segments,
991            "tool-choice",
992            &format!(
993                "The system is invoked with `tool_choice=specified`.\n\
994                 You MUST call the tool `{}` in the next message.",
995                named_tool.expect("specified tool_choice has a function name")
996            ),
997        ),
998        _ => {}
999    }
1000
1001    if let Some(response_format) = response_format {
1002        match response_format.get("type").and_then(Value::as_str) {
1003            Some("json_object") => internal_system_message(
1004                &mut segments,
1005                "response-format",
1006                "The system is invoked with `response_format=json_object`.\n\
1007                 Your response must be raw JSON data without markdown code blocks \
1008                 (```json) or any additional formatting.",
1009            ),
1010            Some("json_schema") => {
1011                let schema = response_schema(response_format)
1012                    .map(deep_sort)
1013                    .unwrap_or(Value::Null);
1014                internal_system_message(
1015                    &mut segments,
1016                    "response-format",
1017                    &format!(
1018                        "The system is invoked with `response_format=json_schema`.\n\
1019                         Your response must be raw JSON data without markdown code blocks \
1020                         (```json) or any additional formatting.\n\
1021                         The JSON data must match the following schema:\n\
1022                         ```json\n{}\n```",
1023                        compact_json(&schema)?
1024                    ),
1025                );
1026            }
1027            _ => {}
1028        }
1029    }
1030
1031    // A partial assistant turn *is* the generation prompt: it is left open so
1032    // the model continues from its prefix, so the generic prompt is skipped
1033    // regardless of `add_generation_prompt`.
1034    if let Some(partial) = partial_tail {
1035        render_partial_assistant_segments(&mut segments, partial, thinking)?;
1036    } else if add_generation_prompt {
1037        open_tag(
1038            &mut segments,
1039            "message",
1040            [("role".to_string(), "assistant".to_string())],
1041        );
1042        open_tag(
1043            &mut segments,
1044            if thinking { "think" } else { "response" },
1045            [],
1046        );
1047    }
1048
1049    Ok(segments)
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055    use minijinja::value::Value as MiniValue;
1056    use serde_json::json;
1057
1058    struct Request {
1059        messages: Value,
1060        tools: Option<Value>,
1061        tool_choice: Option<Value>,
1062        response_format: Option<Value>,
1063        args: HashMap<String, Value>,
1064        add_generation_prompt: bool,
1065    }
1066
1067    impl Request {
1068        fn new(messages: Value) -> Self {
1069            Self {
1070                messages,
1071                tools: None,
1072                tool_choice: None,
1073                response_format: None,
1074                args: HashMap::new(),
1075                add_generation_prompt: true,
1076            }
1077        }
1078    }
1079
1080    impl OAIChatLikeRequest for Request {
1081        fn model(&self) -> String {
1082            "kimi-k3".to_string()
1083        }
1084
1085        fn messages(&self) -> MiniValue {
1086            MiniValue::from_serialize(&self.messages)
1087        }
1088
1089        fn tools(&self) -> Option<MiniValue> {
1090            self.tools.as_ref().map(MiniValue::from_serialize)
1091        }
1092
1093        fn tool_choice(&self) -> Option<MiniValue> {
1094            self.tool_choice.as_ref().map(MiniValue::from_serialize)
1095        }
1096
1097        fn response_format(&self) -> Option<MiniValue> {
1098            self.response_format.as_ref().map(MiniValue::from_serialize)
1099        }
1100
1101        fn should_add_generation_prompt(&self) -> bool {
1102            self.add_generation_prompt
1103        }
1104
1105        fn chat_template_args(&self) -> Option<&HashMap<String, Value>> {
1106            Some(&self.args)
1107        }
1108    }
1109
1110    /// Default formatter: no worker declaration, so the checkpoint token.
1111    fn fmt() -> KimiK3Formatter {
1112        KimiK3Formatter::new(true)
1113    }
1114
1115    /// One user message carrying a single image part.
1116    fn image_request() -> Request {
1117        let mut request = Request::new(json!([{
1118            "role": "user",
1119            "content": [{"type": "image_url", "image_url": {"url": "http://example.com/a.png"}}]
1120        }]));
1121        request
1122            .args
1123            .insert("thinking".to_string(), Value::Bool(false));
1124        request
1125    }
1126
1127    fn image_segments(formatter: &KimiK3Formatter, request: &Request) -> Vec<RenderedSegment> {
1128        formatter
1129            .render_prompt(request)
1130            .unwrap()
1131            .segments()
1132            .expect("K3 always renders segmented prompts")
1133            .to_vec()
1134    }
1135
1136    #[test]
1137    fn renders_one_media_pad_per_image() {
1138        let segments = image_segments(&fmt(), &image_request());
1139
1140        let matches: Vec<_> = segments
1141            .iter()
1142            .filter(|segment| segment.text == MEDIA_PAD)
1143            .collect();
1144        assert_eq!(matches.len(), 1, "exactly one pad per image");
1145        // The pad MUST stay special: it is a registered token, and only the
1146        // special-aware encode path yields its single id.
1147        assert!(matches[0].allow_special);
1148        // The checkpoint's non-vocabulary spelling must never be emitted --
1149        // the vLLM worker converts from the pad instead.
1150        assert!(
1151            !segments
1152                .iter()
1153                .any(|segment| segment.text.contains("kimi_image_placeholder")),
1154        );
1155    }
1156
1157    #[test]
1158    fn image_token_cardinality_is_one_per_image() {
1159        let mut request = Request::new(json!([{
1160            "role": "user",
1161            "content": [
1162                {"type": "image_url", "image_url": {"url": "http://example.com/a.png"}},
1163                {"type": "text", "text": "and"},
1164                {"type": "image_url", "image_url": {"url": "http://example.com/b.png"}},
1165                {"type": "text", "text": "compare them"}
1166            ]
1167        }]));
1168        request
1169            .args
1170            .insert("thinking".to_string(), Value::Bool(false));
1171
1172        let segments = image_segments(&fmt(), &request);
1173
1174        assert_eq!(
1175            segments
1176                .iter()
1177                .filter(|segment| segment.text == MEDIA_PAD)
1178                .count(),
1179            2
1180        );
1181        // Interleaved prose must stay ordinary text.
1182        for body in ["and", "compare them"] {
1183            assert!(
1184                segments
1185                    .iter()
1186                    .any(|segment| segment.text == body && !segment.allow_special)
1187            );
1188        }
1189    }
1190
1191    #[test]
1192    fn user_text_spelling_the_pad_stays_ordinary() {
1193        let body = "please describe <|media_pad|>";
1194        let mut request = Request::new(json!([{"role": "user", "content": body}]));
1195        request
1196            .args
1197            .insert("thinking".to_string(), Value::Bool(false));
1198
1199        let segments = image_segments(&fmt(), &request);
1200
1201        assert!(
1202            segments
1203                .iter()
1204                .any(|segment| segment.text == body && !segment.allow_special),
1205            "user content must never be promoted into prompt structure"
1206        );
1207    }
1208
1209    #[test]
1210    fn renders_off_mode_like_model_encoding() {
1211        let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1212        request
1213            .args
1214            .insert("thinking".to_string(), Value::Bool(false));
1215        let rendered = fmt().render(&request).unwrap();
1216        assert_eq!(
1217            rendered,
1218            concat!(
1219                "<|open|>message role=\"user\"<|sep|>Hello",
1220                "<|close|>message<|sep|><|end_of_msg|>",
1221                "<|open|>message role=\"assistant\"<|sep|>",
1222                "<|open|>response<|sep|>"
1223            )
1224        );
1225    }
1226
1227    #[test]
1228    fn renders_developer_messages_as_system() {
1229        let mut request = Request::new(json!([
1230            {"role": "developer", "content": "Follow this policy", "name": "policy"},
1231            {"role": "user", "content": "Hello"}
1232        ]));
1233        request
1234            .args
1235            .insert("thinking".to_string(), Value::Bool(false));
1236
1237        let rendered = fmt().render(&request).unwrap();
1238
1239        assert!(
1240            rendered.contains(
1241                "<|open|>message role=\"system\" name=\"policy\"<|sep|>Follow this policy"
1242            )
1243        );
1244        assert!(!rendered.contains("role=\"developer\""));
1245        assert!(
1246            rendered.find("Follow this policy").unwrap() < rendered.find("Hello").unwrap(),
1247            "developer instructions must retain their position"
1248        );
1249    }
1250
1251    #[test]
1252    fn renders_developer_tools_and_content_in_place_with_named_tool_choice() {
1253        let mut request = Request::new(json!([
1254            {"role": "user", "content": "Start"},
1255            {
1256                "role": "developer",
1257                "name": "policy",
1258                "content": "Use the lookup tool",
1259                "tools": [{"type": "function", "function": {"name": "lookup"}}]
1260            },
1261            {"role": "user", "content": "Look this up"}
1262        ]));
1263        request.tool_choice = Some(json!({
1264            "type": "function",
1265            "function": {"name": "lookup"}
1266        }));
1267        let rendered = fmt().render(&request).unwrap();
1268        let developer_turn = concat!(
1269            "<|open|>message role=\"system\" name=\"policy\"<|sep|>Use the lookup tool",
1270            "<|close|>message<|sep|><|end_of_msg|>"
1271        );
1272        let declaration = rendered.find("## New Tools Available").unwrap();
1273        let content = rendered.find(developer_turn).unwrap();
1274        assert!(rendered.find("Start").unwrap() < declaration);
1275        assert!(declaration < content);
1276        assert!(content < rendered.find("Look this up").unwrap());
1277        assert!(rendered.contains("\"name\":\"lookup\""));
1278        assert!(rendered.contains("MUST call the tool `lookup`"));
1279
1280        request.messages[1]
1281            .as_object_mut()
1282            .unwrap()
1283            .remove("content");
1284        assert_eq!(
1285            fmt().render(&request).unwrap(),
1286            rendered.replace(developer_turn, "")
1287        );
1288    }
1289
1290    #[test]
1291    fn rejects_tools_on_unsupported_message_roles() {
1292        let tools = json!([{"type": "function", "function": {"name": "lookup"}}]);
1293        for (role, extra) in [
1294            ("user", json!({"content": "Look this up"})),
1295            ("assistant", json!({"content": "ok"})),
1296        ] {
1297            let mut message = extra;
1298            message["role"] = json!(role);
1299            message["tools"] = tools.clone();
1300            let request = Request::new(json!([message, {"role": "user", "content": "Go"}]));
1301
1302            let error = fmt().render(&request).unwrap_err();
1303            assert_eq!(
1304                invalid_request_message(&error),
1305                format!(
1306                    "`tools` is only accepted on system or developer messages, not on role {role}"
1307                ),
1308                "role={role}"
1309            );
1310        }
1311    }
1312
1313    #[test]
1314    fn rejects_unsupported_message_roles() {
1315        for role in ["function", "unknown"] {
1316            let request = Request::new(json!([{"role": role, "content": "ignored before"}]));
1317
1318            let error = fmt().render(&request).unwrap_err();
1319
1320            assert!(matches!(
1321                error.downcast_ref::<PromptRenderError>(),
1322                Some(PromptRenderError::InvalidRequest(message))
1323                    if message == &format!("Kimi K3 does not support message role {role:?}")
1324            ));
1325        }
1326    }
1327
1328    #[test]
1329    fn rejects_messages_without_a_string_role() {
1330        for messages in [json!([{"content": "missing"}]), json!([{"role": 7}])] {
1331            let request = Request::new(messages);
1332
1333            let error = fmt().render(&request).unwrap_err();
1334
1335            assert!(matches!(
1336                error.downcast_ref::<PromptRenderError>(),
1337                Some(PromptRenderError::InvalidRequest(message))
1338                    if message == "Kimi K3 messages must contain a string role"
1339            ));
1340        }
1341    }
1342
1343    #[test]
1344    fn rejects_unsupported_thinking_effort_as_invalid_request() {
1345        let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1346        request.args.insert(
1347            "thinking_effort".to_string(),
1348            Value::String("medium".to_string()),
1349        );
1350
1351        let error = fmt().render(&request).unwrap_err();
1352        assert!(matches!(
1353            error.downcast_ref::<PromptRenderError>(),
1354            Some(PromptRenderError::InvalidRequest(message))
1355                if message.contains("thinking_effort=\"medium\"")
1356        ));
1357    }
1358
1359    // -- Kimi Partial Mode (prefix continuation) --
1360
1361    #[test]
1362    fn partial_assistant_renders_open_turn_in_place_of_generation_prompt() {
1363        let mut request = Request::new(json!([
1364            {"role": "user", "content": "Greet the customer"},
1365            {"role": "assistant", "content": "Dear customer, hello", "partial": true}
1366        ]));
1367        request
1368            .args
1369            .insert("thinking".to_string(), Value::Bool(false));
1370
1371        let rendered = fmt().render(&request).unwrap();
1372
1373        assert_eq!(
1374            rendered,
1375            concat!(
1376                "<|open|>message role=\"user\"<|sep|>Greet the customer",
1377                "<|close|>message<|sep|><|end_of_msg|>",
1378                "<|open|>message role=\"assistant\"<|sep|>",
1379                "<|open|>response<|sep|>Dear customer, hello"
1380            ),
1381            "the partial turn must stay open: no <|close|>response / <|close|>message / <|end_of_msg|>, \
1382             and no extra generation prompt after it"
1383        );
1384        for tool_calls in [Value::Null, json!([])] {
1385            request.messages[1]["tool_calls"] = tool_calls;
1386            assert_eq!(fmt().render(&request).unwrap(), rendered);
1387        }
1388    }
1389
1390    #[test]
1391    fn partial_assistant_ignores_add_generation_prompt_flag() {
1392        let mut request = Request::new(json!([
1393            {"role": "user", "content": "Go"},
1394            {"role": "assistant", "content": "prefix", "partial": true}
1395        ]));
1396        request
1397            .args
1398            .insert("thinking".to_string(), Value::Bool(false));
1399        request.add_generation_prompt = false;
1400
1401        let rendered = fmt().render(&request).unwrap();
1402
1403        assert!(rendered.ends_with("<|open|>response<|sep|>prefix"));
1404        assert_eq!(rendered.matches("role=\"assistant\"").count(), 1);
1405    }
1406
1407    #[test]
1408    fn partial_assistant_in_thinking_mode_closes_think_then_opens_response() {
1409        let mut request = Request::new(json!([
1410            {"role": "user", "content": "Go"},
1411            {
1412                "role": "assistant",
1413                "reasoning_content": "carried over reasoning",
1414                "content": "prefix",
1415                "partial": true
1416            }
1417        ]));
1418        request
1419            .args
1420            .insert("thinking".to_string(), Value::Bool(true));
1421
1422        let rendered = fmt().render(&request).unwrap();
1423
1424        assert!(rendered.ends_with(concat!(
1425            "<|open|>message role=\"assistant\"<|sep|>",
1426            "<|open|>think<|sep|>carried over reasoning<|close|>think<|sep|>",
1427            "<|open|>response<|sep|>prefix"
1428        )));
1429    }
1430
1431    #[test]
1432    fn partial_assistant_keeps_name_as_part_of_the_prefix() {
1433        let mut request = Request::new(json!([
1434            {"role": "user", "content": "Who are you?"},
1435            {"role": "assistant", "name": "Sherlock", "content": "Elementary", "partial": true}
1436        ]));
1437        request
1438            .args
1439            .insert("thinking".to_string(), Value::Bool(false));
1440
1441        let rendered = fmt().render(&request).unwrap();
1442
1443        assert!(rendered.ends_with(concat!(
1444            "<|open|>message role=\"assistant\" name=\"Sherlock\"<|sep|>",
1445            "<|open|>response<|sep|>Elementary"
1446        )));
1447    }
1448
1449    #[test]
1450    fn partial_assistant_follows_internal_system_messages() {
1451        // tool_choice / response_format hints are injected after history and
1452        // before the generation turn; a partial turn must not be split by them.
1453        let mut request = Request::new(json!([
1454            {"role": "user", "content": "Go"},
1455            {"role": "assistant", "content": "prefix", "partial": true}
1456        ]));
1457        request.tools = Some(json!([{
1458            "type": "function",
1459            "function": {"name": "lookup", "parameters": {"type": "object"}}
1460        }]));
1461        request.tool_choice = Some(json!("none"));
1462        request
1463            .args
1464            .insert("thinking".to_string(), Value::Bool(false));
1465
1466        let rendered = fmt().render(&request).unwrap();
1467
1468        let hint = rendered
1469            .find("tool_choice=none")
1470            .expect("tool-choice hint rendered");
1471        let turn = rendered
1472            .rfind("<|open|>message role=\"assistant\"<|sep|>")
1473            .expect("partial turn rendered");
1474        assert!(
1475            hint < turn,
1476            "internal system messages must precede the open partial turn"
1477        );
1478        assert!(rendered.ends_with("<|open|>response<|sep|>prefix"));
1479    }
1480
1481    #[test]
1482    fn partial_false_is_an_ordinary_assistant_turn() {
1483        let mut request = Request::new(json!([
1484            {"role": "user", "content": "Go"},
1485            {"role": "assistant", "content": "done", "partial": false}
1486        ]));
1487        request
1488            .args
1489            .insert("thinking".to_string(), Value::Bool(false));
1490
1491        let rendered = fmt().render(&request).unwrap();
1492
1493        assert!(rendered.contains(
1494            "<|open|>response<|sep|>done<|close|>response<|sep|><|close|>message<|sep|><|end_of_msg|>"
1495        ));
1496        assert!(
1497            rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>")
1498        );
1499    }
1500
1501    #[test]
1502    fn rejects_partial_on_a_non_final_message() {
1503        let request = Request::new(json!([
1504            {"role": "assistant", "content": "early", "partial": true},
1505            {"role": "user", "content": "Go"}
1506        ]));
1507
1508        let error = fmt().render(&request).unwrap_err();
1509
1510        assert!(matches!(
1511            error.downcast_ref::<PromptRenderError>(),
1512            Some(PromptRenderError::InvalidRequest(message))
1513                if message == "Kimi K3 `partial` is only supported on the final message"
1514        ));
1515    }
1516
1517    #[test]
1518    fn rejects_partial_on_a_non_assistant_message() {
1519        let request = Request::new(json!([
1520            {"role": "user", "content": "Go", "partial": false}
1521        ]));
1522        let error = fmt().render(&request).unwrap_err();
1523        assert_eq!(
1524            invalid_request_message(&error),
1525            "Kimi K3 `partial` is only supported on an assistant message"
1526        );
1527    }
1528
1529    #[test]
1530    fn rejects_non_boolean_partial() {
1531        let request = Request::new(json!([
1532            {"role": "assistant", "content": "done", "partial": "true"}
1533        ]));
1534        let error = fmt().render(&request).unwrap_err();
1535        assert_eq!(
1536            invalid_request_message(&error),
1537            "Kimi K3 `partial` must be a boolean"
1538        );
1539    }
1540
1541    #[test]
1542    fn null_partial_is_equivalent_to_absent() {
1543        let mut request = Request::new(json!([
1544            {"role": "user", "content": "Go"}
1545        ]));
1546        let expected = fmt().render(&request).unwrap();
1547        request.messages[0]["partial"] = Value::Null;
1548        assert_eq!(fmt().render(&request).unwrap(), expected);
1549    }
1550
1551    // -- Dynamic tool system messages: content XOR tools --
1552
1553    fn invalid_request_message(error: &anyhow::Error) -> &str {
1554        match error.downcast_ref::<PromptRenderError>() {
1555            Some(PromptRenderError::InvalidRequest(message)) => message,
1556            other => panic!("expected InvalidRequest, got {other:?}"),
1557        }
1558    }
1559
1560    #[test]
1561    fn rejects_system_message_with_both_content_and_tools() {
1562        let request = Request::new(json!([
1563            {
1564                "role": "system",
1565                "content": "You are helpful",
1566                "tools": [{"type": "function", "function": {"name": "lookup"}}]
1567            },
1568            {"role": "user", "content": "Go"}
1569        ]));
1570
1571        let error = fmt().render(&request).unwrap_err();
1572        assert_eq!(
1573            invalid_request_message(&error),
1574            "Kimi K3 system messages carry either `content` or `tools`, not both"
1575        );
1576    }
1577
1578    #[test]
1579    fn rejects_system_message_tools_that_are_not_an_array() {
1580        let request = Request::new(json!([
1581            {"role": "system", "tools": {"name": "lookup"}},
1582            {"role": "user", "content": "Go"}
1583        ]));
1584
1585        let error = fmt().render(&request).unwrap_err();
1586        assert_eq!(
1587            invalid_request_message(&error),
1588            "Kimi K3 dynamic tool messages need `tools` to be an array"
1589        );
1590    }
1591
1592    /// Moonshot's official dynamic-tools verifier sends `"content": ""`
1593    /// alongside `tools` and expects success. Empty content is "omitted".
1594    #[test]
1595    fn accepts_dynamic_tools_with_empty_string_content() {
1596        for empty in [json!(""), json!([]), Value::Null] {
1597            let mut request = Request::new(json!([
1598                {"role": "user", "content": "Start"},
1599                {
1600                    "role": "system",
1601                    "content": empty,
1602                    "tools": [{"type": "function", "function": {"name": "lookup"}}]
1603                },
1604                {"role": "user", "content": "Go"}
1605            ]));
1606            request
1607                .args
1608                .insert("thinking".to_string(), Value::Bool(false));
1609
1610            let rendered = fmt()
1611                .render(&request)
1612                .unwrap_or_else(|e| panic!("content={empty}: {e}"));
1613            assert!(
1614                rendered.contains("## New Tools Available"),
1615                "content={empty}"
1616            );
1617            assert!(
1618                !rendered.contains("<|open|>message role=\"system\"<|sep|><|close|>message"),
1619                "content={empty}: must not emit an empty system turn"
1620            );
1621        }
1622    }
1623
1624    #[test]
1625    fn rejects_malformed_dynamic_tool_entries() {
1626        let long_name = "a".repeat(257);
1627        for (entry, needle) in [
1628            (json!("lookup"), "must be JSON objects"),
1629            (
1630                json!({"parameters": {"type": "object"}}),
1631                "need a string `name`",
1632            ),
1633            (
1634                json!({"type": "web_search", "function": {"name": "lookup"}}),
1635                "type=\"function\"",
1636            ),
1637            (
1638                json!({"function": {"name": "lookup"}}),
1639                "need type=\"function\"",
1640            ),
1641            (
1642                json!({"type": "function", "name": "lookup"}),
1643                "need a `function` object",
1644            ),
1645            (json!({"name": ""}), "must match"),
1646            (json!({"name": "1bad_name"}), "must match"),
1647            (json!({"name": "bad@name"}), "must match"),
1648            (json!({"name": long_name}), "maximum is 256"),
1649        ] {
1650            let request = Request::new(json!([
1651                {"role": "system", "tools": [entry]},
1652                {"role": "user", "content": "Go"}
1653            ]));
1654            let error = fmt().render(&request).unwrap_err();
1655            assert!(
1656                invalid_request_message(&error).contains(needle),
1657                "entry={entry}: {}",
1658                invalid_request_message(&error)
1659            );
1660        }
1661    }
1662
1663    #[test]
1664    fn rejects_duplicate_tool_names_across_declarations() {
1665        let mut request = Request::new(json!([
1666            {"role": "system", "tools": [{"name": "lookup"}]},
1667            {"role": "user", "content": "Go"}
1668        ]));
1669        request.tools = Some(json!([{
1670            "type": "function",
1671            "function": {"name": "lookup", "parameters": {"type": "object"}}
1672        }]));
1673        let error = fmt().render(&request).unwrap_err();
1674        assert!(invalid_request_message(&error).contains("declared more than once"));
1675
1676        request.messages[0]["role"] = json!("developer");
1677        let error = fmt().render(&request).unwrap_err();
1678        assert!(invalid_request_message(&error).contains("declared more than once"));
1679
1680        let mut request = Request::new(json!([{"role": "user", "content": "Go"}]));
1681        request.tools = Some(json!([
1682            {"type": "function", "function": {"name": "lookup"}},
1683            {"type": "function", "function": {"name": "lookup"}}
1684        ]));
1685        let error = fmt().render(&request).unwrap_err();
1686        assert!(invalid_request_message(&error).contains("more than once in `tools`"));
1687
1688        let max_name = "a".repeat(256);
1689        let request = Request::new(json!([
1690            {"role": "system", "tools": [
1691                {"name": "_private-tool_2"},
1692                {"type": "function", "function": {"name": max_name}}
1693            ]},
1694            {"role": "user", "content": "Go"}
1695        ]));
1696        fmt().render(&request).unwrap();
1697
1698        let mut request = Request::new(json!([
1699            {"role": "system", "tools": [{"name": "lookup"}, {"type": "function", "function": {"name": "search"}}]},
1700            {"role": "user", "content": "Go"}
1701        ]));
1702        request.tools = Some(json!([{
1703            "type": "function",
1704            "function": {"name": "add", "parameters": {"type": "object"}}
1705        }]));
1706        fmt().render(&request).unwrap();
1707    }
1708
1709    #[test]
1710    fn empty_tools_list_is_an_ordinary_system_message() {
1711        let mut request = Request::new(json!([
1712            {"role": "system", "content": "You are helpful", "tools": []},
1713            {"role": "user", "content": "Go"}
1714        ]));
1715        request
1716            .args
1717            .insert("thinking".to_string(), Value::Bool(false));
1718
1719        let rendered = fmt().render(&request).unwrap();
1720        assert!(rendered.contains("<|open|>message role=\"system\"<|sep|>You are helpful"));
1721        assert!(!rendered.contains("## New Tools Available"));
1722
1723        let request = Request::new(json!([
1724            {"role": "system", "tools": []},
1725            {"role": "user", "content": "Go"}
1726        ]));
1727        let error = fmt().render(&request).unwrap_err();
1728        assert_eq!(
1729            invalid_request_message(&error),
1730            "Kimi K3 system messages need `content` or `tools`"
1731        );
1732    }
1733
1734    #[test]
1735    fn rejects_system_message_with_neither_content_nor_tools() {
1736        let request = Request::new(json!([
1737            {"role": "system"},
1738            {"role": "user", "content": "Go"}
1739        ]));
1740
1741        let error = fmt().render(&request).unwrap_err();
1742        assert_eq!(
1743            invalid_request_message(&error),
1744            "Kimi K3 system messages need `content` or `tools`"
1745        );
1746    }
1747
1748    // -- Typed request path: JSON -> CreateChatCompletionRequest -> renderer --
1749    //
1750    // The raw-JSON `Request` above bypasses protocol deserialization. These
1751    // tests go through `dynamo_protocols::types::CreateChatCompletionRequest`
1752    // and its default `OAIChatLikeRequest` impl, which is what an HTTP frontend
1753    // actually hands to the formatter.
1754
1755    fn typed(body: Value) -> dynamo_protocols::types::CreateChatCompletionRequest {
1756        serde_json::from_value(body).expect("request deserializes")
1757    }
1758
1759    #[test]
1760    fn typed_request_rejects_invalid_top_level_tool_name() {
1761        let request = typed(json!({
1762            "model": "kimi-k3",
1763            "messages": [{"role": "user", "content": "Look it up"}],
1764            "tools": [{
1765                "type": "function",
1766                "function": {"name": "bad@name", "parameters": {"type": "object"}}
1767            }]
1768        }));
1769
1770        let error = fmt().render(&request).unwrap_err();
1771        assert_eq!(
1772            invalid_request_message(&error),
1773            "Kimi K3 tool name \"bad@name\" must match [A-Za-z_][A-Za-z0-9_-]*"
1774        );
1775    }
1776
1777    #[test]
1778    fn typed_request_renders_dynamic_tools_and_final_partial_end_to_end() {
1779        let request = typed(json!({
1780            "model": "kimi-k3",
1781            "messages": [
1782                {"role": "system", "tools": [{
1783                    "type": "function",
1784                    "function": {"name": "lookup", "parameters": {"type": "object"}}
1785                }]},
1786                {"role": "user", "content": "Look it up"},
1787                {"role": "assistant", "content": "Looking", "partial": true}
1788            ]
1789        }));
1790
1791        let rendered = fmt().render(&request).unwrap();
1792
1793        assert!(rendered.contains("## New Tools Available"));
1794        assert!(rendered.contains("\"lookup\""));
1795        assert!(
1796            rendered.ends_with("<|open|>response<|sep|>Looking"),
1797            "partial turn must stay open, got {rendered:?}"
1798        );
1799    }
1800
1801    #[test]
1802    fn typed_request_preserves_content_and_tools_for_renderer_conflict_check() {
1803        let request = typed(json!({
1804            "model": "kimi-k3",
1805            "messages": [
1806                {
1807                    "role": "system",
1808                    "content": "You are helpful",
1809                    "tools": [{"type": "function", "function": {"name": "lookup"}}]
1810                },
1811                {"role": "user", "content": "Go"}
1812            ]
1813        }));
1814
1815        let system = serde_json::to_value(&request.messages[0]).unwrap();
1816        assert_eq!(system["content"], json!("You are helpful"));
1817        assert_eq!(system["tools"][0]["function"]["name"], json!("lookup"));
1818
1819        let error = fmt().render(&request).unwrap_err();
1820        assert_eq!(
1821            invalid_request_message(&error),
1822            "Kimi K3 system messages carry either `content` or `tools`, not both"
1823        );
1824    }
1825
1826    #[test]
1827    fn rejects_partial_assistant_with_tool_calls() {
1828        let tool_call = json!({
1829            "id": "call_1",
1830            "type": "function",
1831            "function": {"name": "lookup", "arguments": "{}"}
1832        });
1833        for tool_calls in [json!([tool_call.clone()]), tool_call] {
1834            let request = Request::new(json!([
1835                {"role": "user", "content": "Go"},
1836                {
1837                    "role": "assistant",
1838                    "content": "prefix",
1839                    "partial": true,
1840                    "tool_calls": tool_calls
1841                }
1842            ]));
1843            let error = fmt().render(&request).unwrap_err();
1844            assert_eq!(
1845                invalid_request_message(&error),
1846                "Kimi K3 partial assistant messages cannot carry tool_calls",
1847                "tool_calls={tool_calls}"
1848            );
1849        }
1850    }
1851
1852    #[test]
1853    fn rejects_tools_on_final_partial_assistant_raw_path() {
1854        let request = Request::new(json!([
1855            {"role": "user", "content": "Go"},
1856            {
1857                "role": "assistant",
1858                "content": "prefix",
1859                "partial": true,
1860                "tools": [{"type": "function", "function": {"name": "lookup"}}]
1861            }
1862        ]));
1863
1864        let error = fmt().render(&request).unwrap_err();
1865
1866        assert!(matches!(
1867            error.downcast_ref::<PromptRenderError>(),
1868            Some(PromptRenderError::InvalidRequest(message))
1869                if message == "`tools` is only accepted on system or developer messages, not on role assistant"
1870        ));
1871    }
1872
1873    #[test]
1874    fn named_tool_choice_forces_tool_and_disables_thinking() {
1875        let mut request = Request::new(json!([
1876            {"role": "user", "content": "What did you do before?"},
1877            {
1878                "role": "assistant",
1879                "reasoning_content": "historical hidden reasoning",
1880                "content": "I answered the earlier question."
1881            },
1882            {"role": "user", "content": "Calculate"}
1883        ]));
1884        request.tools = Some(json!([{
1885            "type": "function",
1886            "function": {
1887                "name": "add_numbers",
1888                "parameters": {
1889                    "type": "object",
1890                    "properties": {
1891                        "a": {"type": "integer"},
1892                        "b": {"type": "integer"}
1893                    },
1894                    "required": ["a", "b"]
1895                }
1896            }
1897        }]));
1898        request.tool_choice = Some(json!({
1899            "type": "function",
1900            "function": {"name": "add_numbers"}
1901        }));
1902        request
1903            .args
1904            .insert("thinking".to_string(), Value::Bool(true));
1905
1906        let rendered = fmt().render(&request).unwrap();
1907        assert!(rendered.contains("The system is invoked with `tool_choice=specified`."));
1908        assert!(rendered.contains("MUST call the tool `add_numbers`"));
1909        assert!(
1910            rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>"),
1911            "named tool choice must use K3's non-thinking generation prefix"
1912        );
1913        assert!(
1914            !rendered.contains("<|open|>think<|sep|>"),
1915            "named tool choice must override thinking=true"
1916        );
1917        assert!(
1918            !rendered.contains("historical hidden reasoning"),
1919            "named tool choice must also suppress preserved thinking history"
1920        );
1921    }
1922
1923    #[test]
1924    fn named_tool_choice_accepts_a_dynamic_system_tool() {
1925        for lookup in [
1926            json!({"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}),
1927            json!({"name": "lookup", "parameters": {"type": "object"}}),
1928        ] {
1929            let mut request = Request::new(json!([
1930                {"role": "user", "content": "Start"},
1931                {"role": "system", "tools": [lookup]},
1932                {"role": "user", "content": "Look this up"}
1933            ]));
1934            request.tool_choice = Some(json!({
1935                "type": "function",
1936                "function": {"name": "lookup"}
1937            }));
1938
1939            let rendered = fmt().render(&request).unwrap();
1940            assert!(rendered.contains("## New Tools Available"));
1941            assert!(rendered.contains("MUST call the tool `lookup`"));
1942            assert!(
1943                request.tools.is_none(),
1944                "dynamic tools must not be folded into the top-level list"
1945            );
1946        }
1947    }
1948
1949    #[test]
1950    fn named_tool_choice_still_rejects_a_tool_absent_from_dynamic_tools() {
1951        let mut request = Request::new(json!([
1952            {"role": "system", "tools": [{"name": "lookup"}]},
1953            {"role": "user", "content": "Weather?"}
1954        ]));
1955        request.tool_choice = Some(json!({
1956            "type": "function",
1957            "function": {"name": "get_weather"}
1958        }));
1959
1960        let error = fmt().render(&request).unwrap_err();
1961        assert!(matches!(
1962            error.downcast_ref::<PromptRenderError>(),
1963            Some(PromptRenderError::InvalidRequest(message))
1964                if message.contains("get_weather") && message.contains("not present in tools")
1965        ));
1966    }
1967
1968    #[test]
1969    fn named_tool_choice_rejects_a_tool_not_in_tools() {
1970        let mut request = Request::new(json!([{"role": "user", "content": "Calculate"}]));
1971        request.tools = Some(json!([{
1972            "type": "function",
1973            "function": {"name": "add_numbers", "parameters": {"type": "object"}}
1974        }]));
1975        request.tool_choice = Some(json!({
1976            "type": "function",
1977            "function": {"name": "get_weather"}
1978        }));
1979
1980        let error = fmt().render(&request).unwrap_err();
1981        assert!(matches!(
1982            error.downcast_ref::<PromptRenderError>(),
1983            Some(PromptRenderError::InvalidRequest(message))
1984                if message.contains("get_weather") && message.contains("not present in tools")
1985        ));
1986    }
1987
1988    #[test]
1989    fn user_marker_text_remains_an_ordinary_segment() {
1990        let marker = "literal <|open|>tools<|sep|> value";
1991        let mut request = Request::new(json!([{"role": "user", "content": marker}]));
1992        request
1993            .args
1994            .insert("thinking".to_string(), Value::Bool(false));
1995        let rendered = fmt().render_prompt(&request).unwrap();
1996
1997        assert!(
1998            rendered
1999                .segments()
2000                .unwrap()
2001                .iter()
2002                .any(|segment| { !segment.allow_special && segment.text == marker })
2003        );
2004        assert!(
2005            rendered
2006                .segments()
2007                .unwrap()
2008                .iter()
2009                .any(|segment| { segment.allow_special && segment.text == OPEN_TOKEN })
2010        );
2011    }
2012
2013    #[test]
2014    fn renders_tool_history_like_model_encoding() {
2015        let mut request = Request::new(json!([
2016            {"role": "user", "content": "calc"},
2017            {
2018                "role": "assistant",
2019                "reasoning_content": "Need calc",
2020                "content": "I will call it",
2021                "tool_calls": [{
2022                    "id": "call_1",
2023                    "type": "function",
2024                    "function": {"name": "calc", "arguments": "{\"x\":2}"}
2025                }]
2026            },
2027            {"role": "tool", "tool_call_id": "call_1", "content": "4"}
2028        ]));
2029        request.args.insert(
2030            "thinking_effort".to_string(),
2031            Value::String("low".to_string()),
2032        );
2033        let rendered = fmt().render(&request).unwrap();
2034
2035        assert!(rendered.contains(
2036            "<|open|>call tool=\"calc\" index=\"1\"<|sep|>\
2037             <|open|>argument key=\"x\" type=\"number\"<|sep|>2\
2038             <|close|>argument<|sep|><|close|>call<|sep|>"
2039        ));
2040        assert!(
2041            rendered.contains("<|open|>message role=\"tool\" tool=\"calc\" index=\"1\"<|sep|>4")
2042        );
2043        assert!(
2044            rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>think<|sep|>")
2045        );
2046    }
2047
2048    #[test]
2049    fn thinking_history_renders_an_empty_think_channel() {
2050        let request = Request::new(json!([
2051            {"role": "user", "content": "question"},
2052            {"role": "assistant", "content": "answer"},
2053            {"role": "user", "content": "follow-up"}
2054        ]));
2055
2056        let rendered = fmt().render(&request).unwrap();
2057
2058        assert!(rendered.contains(concat!(
2059            "<|open|>message role=\"assistant\"<|sep|>",
2060            "<|open|>think<|sep|><|close|>think<|sep|>",
2061            "<|open|>response<|sep|>answer<|close|>response<|sep|>"
2062        )));
2063    }
2064
2065    #[test]
2066    fn non_thinking_history_omits_preserved_reasoning() {
2067        let mut request = Request::new(json!([
2068            {"role": "user", "content": "question"},
2069            {
2070                "role": "assistant",
2071                "reasoning_content": "hidden reasoning",
2072                "content": "answer"
2073            },
2074            {"role": "user", "content": "follow-up"}
2075        ]));
2076        request
2077            .args
2078            .insert("thinking".to_string(), Value::Bool(false));
2079
2080        let rendered = fmt().render(&request).unwrap();
2081
2082        assert!(!rendered.contains("hidden reasoning"));
2083        assert!(!rendered.contains("<|open|>think<|sep|>"));
2084        assert!(rendered.contains(concat!(
2085            "<|open|>message role=\"assistant\"<|sep|>",
2086            "<|open|>response<|sep|>answer<|close|>response<|sep|>"
2087        )));
2088    }
2089
2090    #[test]
2091    fn tools_are_deep_sorted_before_declaration() {
2092        let mut request = Request::new(json!([{"role": "user", "content": "Weather?"}]));
2093        request
2094            .args
2095            .insert("thinking".to_string(), Value::Bool(false));
2096        request.tools = Some(json!([{
2097            "type": "function",
2098            "function": {
2099                "parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
2100                "name": "weather",
2101                "description": "Get weather"
2102            }
2103        }]));
2104        let rendered = fmt().render(&request).unwrap();
2105        assert!(rendered.contains(concat!(
2106            "[{\"function\":{\"description\":\"Get weather\",",
2107            "\"name\":\"weather\",\"parameters\":{\"properties\":",
2108            "{\"city\":{\"type\":\"string\"}},\"type\":\"object\"}},",
2109            "\"type\":\"function\"}]"
2110        )));
2111    }
2112
2113    #[test]
2114    fn assistant_history_matches_python_json_spacing_and_reasoning_fallback() {
2115        let request = Request::new(json!([{
2116            "role": "assistant",
2117            "reasoning_content": "",
2118            "reasoning": "fallback",
2119            "content": null,
2120            "tool_calls": [{
2121                "type": "function",
2122                "function": {
2123                    "name": "run",
2124                    "arguments": {
2125                        "opts": {"a": 1, "b": [true, false]}
2126                    }
2127                }
2128            }]
2129        }]));
2130        let rendered = fmt().render(&request).unwrap();
2131
2132        assert!(rendered.contains("<|open|>think<|sep|>fallback<|close|>think<|sep|>"));
2133        assert!(rendered.contains(concat!(
2134            "<|open|>argument key=\"opts\" type=\"object\"<|sep|>",
2135            "{\"a\": 1, \"b\": [true, false]}",
2136            "<|close|>argument<|sep|>"
2137        )));
2138    }
2139}