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 = crate::messages_to_json(req).context("Failed to convert K3 messages")?;
60        let Value::Array(messages) = messages else {
61            anyhow::bail!("Kimi K3 messages must be an array");
62        };
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: Vec<Value>) -> Result<Vec<Value>> {
754    let mut output = Vec::with_capacity(messages.len());
755    let mut current_index = HashMap::new();
756    let mut messages = messages.into_iter().peekable();
757
758    while let Some(message) = messages.peek() {
759        let role = message.get("role").and_then(Value::as_str);
760        if role == Some("assistant") {
761            current_index = tool_call_index(message.get("tool_calls"));
762            output.push(messages.next().expect("peeked message"));
763            continue;
764        }
765        if role != Some("tool") {
766            output.push(messages.next().expect("peeked message"));
767            continue;
768        }
769
770        let mut run: Vec<(Option<usize>, usize, Value, Option<String>)> = Vec::new();
771        let mut unresolved = false;
772        let mut offset = 0;
773        while messages
774            .peek()
775            .is_some_and(|message| message.get("role").and_then(Value::as_str) == Some("tool"))
776        {
777            let tool_message = messages.next().expect("peeked tool message");
778            let call_id = tool_message
779                .get("tool_call_id")
780                .or_else(|| tool_message.get("id"))
781                .and_then(Value::as_str);
782            let matched = call_id.and_then(|id| current_index.get(id));
783            if let Some((tool_position, name)) = matched {
784                run.push((Some(*tool_position), offset, tool_message, name.clone()));
785            } else {
786                unresolved = true;
787                run.push((None, offset, tool_message, None));
788            }
789            offset += 1;
790        }
791
792        if unresolved {
793            output.extend(run.into_iter().map(|(_, _, message, _)| message));
794            continue;
795        }
796        run.sort_by_key(|(tool_position, offset, _, _)| (*tool_position, *offset));
797        for (_, _, mut message, name) in run {
798            if let (Some(name), Some(message)) = (name, message.as_object_mut()) {
799                message.insert("tool".to_string(), Value::String(name.clone()));
800                if message.contains_key("name") {
801                    message.insert("name".to_string(), Value::String(name));
802                }
803            }
804            output.push(message);
805        }
806    }
807    Ok(output)
808}
809
810#[allow(clippy::too_many_arguments)]
811fn build_chat_segments(
812    messages: &[Value],
813    tools: Option<&Value>,
814    tool_choice: Option<&str>,
815    named_tool: Option<&str>,
816    response_format: Option<&Value>,
817    add_generation_prompt: bool,
818    thinking: bool,
819    thinking_effort: &str,
820) -> Result<Vec<RenderedSegment>> {
821    let mut segments = Vec::new();
822    let mut previous_tool_calls: Option<&Value> = None;
823    let mut tool_index = 0usize;
824
825    for message in messages {
826        let Some(partial) = message.get("partial").filter(|value| !value.is_null()) else {
827            continue;
828        };
829        if message.get("role").and_then(Value::as_str) != Some("assistant") {
830            return Err(PromptRenderError::invalid_request(
831                "Kimi K3 `partial` is only supported on an assistant message",
832            )
833            .into());
834        }
835        if !partial.is_boolean() {
836            return Err(
837                PromptRenderError::invalid_request("Kimi K3 `partial` must be a boolean").into(),
838            );
839        }
840    }
841
842    // Kimi Partial Mode: only the final message may be partial, and it must be
843    // an assistant turn. Split it off so the history loop renders everything
844    // before it normally and the partial turn takes the generation prompt's
845    // place at the very end (after any internal system messages).
846    let (history, partial_tail) = match messages.split_last() {
847        Some((last, history)) if is_partial(last) => (history, Some(last)),
848        _ => (messages, None),
849    };
850
851    // Validate the complete raw message list, including a split-off Partial
852    // Mode tail. Otherwise `tools` on the final partial assistant message
853    // bypasses the supported-role check below.
854    validate_tool_declarations(tools, messages)?;
855    if history.iter().any(is_partial) {
856        return Err(PromptRenderError::invalid_request(
857            "Kimi K3 `partial` is only supported on the final message",
858        )
859        .into());
860    }
861
862    if let Some(tools) = tools.filter(|tools| !tools.as_array().is_some_and(Vec::is_empty)) {
863        render_tool_declare(&mut segments, tools, false)?;
864    }
865
866    if thinking {
867        internal_system_message(
868            &mut segments,
869            "thinking-effort",
870            &format!(
871                "`thinking_effort` guides on how much to think in your thinking channel \
872                 (not including the response channel), supported values include `low`, \
873                 `medium`, `high`, and `max`.\nNow the system is invoked with \
874                 `thinking_effort={thinking_effort}`."
875            ),
876        );
877    }
878
879    for message in history {
880        let role = message.get("role").and_then(Value::as_str).ok_or_else(|| {
881            PromptRenderError::invalid_request("Kimi K3 messages must contain a string role")
882        })?;
883        // An empty `tools` list is not a dynamic-tool declaration.
884        let dynamic_tools = dynamic_tools_of(message)?;
885        match role {
886            "system" | "developer" if dynamic_tools.is_some() => {
887                let dynamic_tools = dynamic_tools.expect("guarded by the match arm");
888                // Moonshot's contract: a dynamic-tool system message omits
889                // `content` (an empty string counts as omitted; the official
890                // verifier sends `"content": ""`). Rejecting non-empty text
891                // keeps it from being silently lost.
892                if role == "system" && content_is_non_empty(message.get("content")) {
893                    return Err(PromptRenderError::invalid_request(
894                        "Kimi K3 system messages carry either `content` or `tools`, not both",
895                    )
896                    .into());
897                }
898                let dynamic_tools = deep_sort(Value::Array(dynamic_tools.clone()));
899                render_tool_declare(&mut segments, &dynamic_tools, true)?;
900                if role == "developer"
901                    && message
902                        .get("content")
903                        .is_some_and(|content| !content.is_null())
904                {
905                    render_role_message(&mut segments, message, "system")?;
906                }
907            }
908            "system" | "developer"
909                if message
910                    .get("content")
911                    .is_none_or(|content| content.is_null()) =>
912            {
913                return Err(PromptRenderError::invalid_request(format!(
914                    "Kimi K3 {role} messages need `content` or `tools`"
915                ))
916                .into());
917            }
918            "user" | "system" | "developer" => {
919                let rendered_role = if role == "developer" { "system" } else { role };
920                render_role_message(&mut segments, message, rendered_role)?;
921            }
922            "assistant" => {
923                previous_tool_calls = message.get("tool_calls");
924                tool_index = 0;
925                open_tag(&mut segments, "message", assistant_message_attrs(message));
926                render_assistant_segments(&mut segments, message, thinking)?;
927                close_tag(&mut segments, "message");
928                end_of_msg(&mut segments);
929            }
930            "tool" => {
931                tool_index += 1;
932                let fallback_name = previous_tool_calls
933                    .and_then(Value::as_array)
934                    .and_then(|calls| calls.get(tool_index - 1))
935                    .map(|call| call.get("function").unwrap_or(call))
936                    .and_then(|function| function.get("name"))
937                    .and_then(Value::as_str);
938                let tool_name = message
939                    .get("tool")
940                    .or_else(|| message.get("name"))
941                    .and_then(Value::as_str)
942                    .or(fallback_name)
943                    .context(
944                        "Kimi K3 tool messages need a tool/name or a preceding assistant tool call",
945                    )?;
946                open_tag(
947                    &mut segments,
948                    "message",
949                    [
950                        ("role".to_string(), "tool".to_string()),
951                        ("tool".to_string(), tool_name.to_string()),
952                        ("index".to_string(), tool_index.to_string()),
953                    ],
954                );
955                render_content_segments(&mut segments, message.get("content"))?;
956                close_tag(&mut segments, "message");
957                end_of_msg(&mut segments);
958            }
959            unsupported => {
960                return Err(PromptRenderError::invalid_request(format!(
961                    "Kimi K3 does not support message role {unsupported:?}"
962                ))
963                .into());
964            }
965        }
966    }
967
968    match tool_choice {
969        Some("required") => internal_system_message(
970            &mut segments,
971            "tool-choice",
972            "The system is invoked with `tool_choice=required`.\n\
973             You MUST call tools in the next message.",
974        ),
975        Some("none") => internal_system_message(
976            &mut segments,
977            "tool-choice",
978            "The system is invoked with `tool_choice=none`.\n\
979             You MUST NOT call any tools in the next message.",
980        ),
981        Some("specified") => internal_system_message(
982            &mut segments,
983            "tool-choice",
984            &format!(
985                "The system is invoked with `tool_choice=specified`.\n\
986                 You MUST call the tool `{}` in the next message.",
987                named_tool.expect("specified tool_choice has a function name")
988            ),
989        ),
990        _ => {}
991    }
992
993    if let Some(response_format) = response_format {
994        match response_format.get("type").and_then(Value::as_str) {
995            Some("json_object") => internal_system_message(
996                &mut segments,
997                "response-format",
998                "The system is invoked with `response_format=json_object`.\n\
999                 Your response must be raw JSON data without markdown code blocks \
1000                 (```json) or any additional formatting.",
1001            ),
1002            Some("json_schema") => {
1003                let schema = response_schema(response_format)
1004                    .map(deep_sort)
1005                    .unwrap_or(Value::Null);
1006                internal_system_message(
1007                    &mut segments,
1008                    "response-format",
1009                    &format!(
1010                        "The system is invoked with `response_format=json_schema`.\n\
1011                         Your response must be raw JSON data without markdown code blocks \
1012                         (```json) or any additional formatting.\n\
1013                         The JSON data must match the following schema:\n\
1014                         ```json\n{}\n```",
1015                        compact_json(&schema)?
1016                    ),
1017                );
1018            }
1019            _ => {}
1020        }
1021    }
1022
1023    // A partial assistant turn *is* the generation prompt: it is left open so
1024    // the model continues from its prefix, so the generic prompt is skipped
1025    // regardless of `add_generation_prompt`.
1026    if let Some(partial) = partial_tail {
1027        render_partial_assistant_segments(&mut segments, partial, thinking)?;
1028    } else if add_generation_prompt {
1029        open_tag(
1030            &mut segments,
1031            "message",
1032            [("role".to_string(), "assistant".to_string())],
1033        );
1034        open_tag(
1035            &mut segments,
1036            if thinking { "think" } else { "response" },
1037            [],
1038        );
1039    }
1040
1041    Ok(segments)
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047    use minijinja::value::Value as MiniValue;
1048    use serde_json::json;
1049
1050    struct Request {
1051        messages: Value,
1052        tools: Option<Value>,
1053        tool_choice: Option<Value>,
1054        response_format: Option<Value>,
1055        args: HashMap<String, Value>,
1056        add_generation_prompt: bool,
1057    }
1058
1059    impl Request {
1060        fn new(messages: Value) -> Self {
1061            Self {
1062                messages,
1063                tools: None,
1064                tool_choice: None,
1065                response_format: None,
1066                args: HashMap::new(),
1067                add_generation_prompt: true,
1068            }
1069        }
1070    }
1071
1072    impl OAIChatLikeRequest for Request {
1073        fn model(&self) -> String {
1074            "kimi-k3".to_string()
1075        }
1076
1077        fn messages(&self) -> MiniValue {
1078            MiniValue::from_serialize(&self.messages)
1079        }
1080
1081        fn tools(&self) -> Option<MiniValue> {
1082            self.tools.as_ref().map(MiniValue::from_serialize)
1083        }
1084
1085        fn tool_choice(&self) -> Option<MiniValue> {
1086            self.tool_choice.as_ref().map(MiniValue::from_serialize)
1087        }
1088
1089        fn response_format(&self) -> Option<MiniValue> {
1090            self.response_format.as_ref().map(MiniValue::from_serialize)
1091        }
1092
1093        fn should_add_generation_prompt(&self) -> bool {
1094            self.add_generation_prompt
1095        }
1096
1097        fn chat_template_args(&self) -> Option<&HashMap<String, Value>> {
1098            Some(&self.args)
1099        }
1100    }
1101
1102    /// Default formatter: no worker declaration, so the checkpoint token.
1103    fn fmt() -> KimiK3Formatter {
1104        KimiK3Formatter::new(true)
1105    }
1106
1107    /// One user message carrying a single image part.
1108    fn image_request() -> Request {
1109        let mut request = Request::new(json!([{
1110            "role": "user",
1111            "content": [{"type": "image_url", "image_url": {"url": "http://example.com/a.png"}}]
1112        }]));
1113        request
1114            .args
1115            .insert("thinking".to_string(), Value::Bool(false));
1116        request
1117    }
1118
1119    fn image_segments(formatter: &KimiK3Formatter, request: &Request) -> Vec<RenderedSegment> {
1120        formatter
1121            .render_prompt(request)
1122            .unwrap()
1123            .segments()
1124            .expect("K3 always renders segmented prompts")
1125            .to_vec()
1126    }
1127
1128    #[test]
1129    fn renders_one_media_pad_per_image() {
1130        let segments = image_segments(&fmt(), &image_request());
1131
1132        let matches: Vec<_> = segments
1133            .iter()
1134            .filter(|segment| segment.text == MEDIA_PAD)
1135            .collect();
1136        assert_eq!(matches.len(), 1, "exactly one pad per image");
1137        // The pad MUST stay special: it is a registered token, and only the
1138        // special-aware encode path yields its single id.
1139        assert!(matches[0].allow_special);
1140        // The checkpoint's non-vocabulary spelling must never be emitted --
1141        // the vLLM worker converts from the pad instead.
1142        assert!(
1143            !segments
1144                .iter()
1145                .any(|segment| segment.text.contains("kimi_image_placeholder")),
1146        );
1147    }
1148
1149    #[test]
1150    fn image_token_cardinality_is_one_per_image() {
1151        let mut request = Request::new(json!([{
1152            "role": "user",
1153            "content": [
1154                {"type": "image_url", "image_url": {"url": "http://example.com/a.png"}},
1155                {"type": "text", "text": "and"},
1156                {"type": "image_url", "image_url": {"url": "http://example.com/b.png"}},
1157                {"type": "text", "text": "compare them"}
1158            ]
1159        }]));
1160        request
1161            .args
1162            .insert("thinking".to_string(), Value::Bool(false));
1163
1164        let segments = image_segments(&fmt(), &request);
1165
1166        assert_eq!(
1167            segments
1168                .iter()
1169                .filter(|segment| segment.text == MEDIA_PAD)
1170                .count(),
1171            2
1172        );
1173        // Interleaved prose must stay ordinary text.
1174        for body in ["and", "compare them"] {
1175            assert!(
1176                segments
1177                    .iter()
1178                    .any(|segment| segment.text == body && !segment.allow_special)
1179            );
1180        }
1181    }
1182
1183    #[test]
1184    fn user_text_spelling_the_pad_stays_ordinary() {
1185        let body = "please describe <|media_pad|>";
1186        let mut request = Request::new(json!([{"role": "user", "content": body}]));
1187        request
1188            .args
1189            .insert("thinking".to_string(), Value::Bool(false));
1190
1191        let segments = image_segments(&fmt(), &request);
1192
1193        assert!(
1194            segments
1195                .iter()
1196                .any(|segment| segment.text == body && !segment.allow_special),
1197            "user content must never be promoted into prompt structure"
1198        );
1199    }
1200
1201    #[test]
1202    fn renders_off_mode_like_model_encoding() {
1203        let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1204        request
1205            .args
1206            .insert("thinking".to_string(), Value::Bool(false));
1207        let rendered = fmt().render(&request).unwrap();
1208        assert_eq!(
1209            rendered,
1210            concat!(
1211                "<|open|>message role=\"user\"<|sep|>Hello",
1212                "<|close|>message<|sep|><|end_of_msg|>",
1213                "<|open|>message role=\"assistant\"<|sep|>",
1214                "<|open|>response<|sep|>"
1215            )
1216        );
1217    }
1218
1219    #[test]
1220    fn renders_developer_messages_as_system() {
1221        let mut request = Request::new(json!([
1222            {"role": "developer", "content": "Follow this policy", "name": "policy"},
1223            {"role": "user", "content": "Hello"}
1224        ]));
1225        request
1226            .args
1227            .insert("thinking".to_string(), Value::Bool(false));
1228
1229        let rendered = fmt().render(&request).unwrap();
1230
1231        assert!(
1232            rendered.contains(
1233                "<|open|>message role=\"system\" name=\"policy\"<|sep|>Follow this policy"
1234            )
1235        );
1236        assert!(!rendered.contains("role=\"developer\""));
1237        assert!(
1238            rendered.find("Follow this policy").unwrap() < rendered.find("Hello").unwrap(),
1239            "developer instructions must retain their position"
1240        );
1241    }
1242
1243    #[test]
1244    fn renders_developer_tools_and_content_in_place_with_named_tool_choice() {
1245        let mut request = Request::new(json!([
1246            {"role": "user", "content": "Start"},
1247            {
1248                "role": "developer",
1249                "name": "policy",
1250                "content": "Use the lookup tool",
1251                "tools": [{"type": "function", "function": {"name": "lookup"}}]
1252            },
1253            {"role": "user", "content": "Look this up"}
1254        ]));
1255        request.tool_choice = Some(json!({
1256            "type": "function",
1257            "function": {"name": "lookup"}
1258        }));
1259        let rendered = fmt().render(&request).unwrap();
1260        let developer_turn = concat!(
1261            "<|open|>message role=\"system\" name=\"policy\"<|sep|>Use the lookup tool",
1262            "<|close|>message<|sep|><|end_of_msg|>"
1263        );
1264        let declaration = rendered.find("## New Tools Available").unwrap();
1265        let content = rendered.find(developer_turn).unwrap();
1266        assert!(rendered.find("Start").unwrap() < declaration);
1267        assert!(declaration < content);
1268        assert!(content < rendered.find("Look this up").unwrap());
1269        assert!(rendered.contains("\"name\":\"lookup\""));
1270        assert!(rendered.contains("MUST call the tool `lookup`"));
1271
1272        request.messages[1]
1273            .as_object_mut()
1274            .unwrap()
1275            .remove("content");
1276        assert_eq!(
1277            fmt().render(&request).unwrap(),
1278            rendered.replace(developer_turn, "")
1279        );
1280    }
1281
1282    #[test]
1283    fn rejects_tools_on_unsupported_message_roles() {
1284        let tools = json!([{"type": "function", "function": {"name": "lookup"}}]);
1285        for (role, extra) in [
1286            ("user", json!({"content": "Look this up"})),
1287            ("assistant", json!({"content": "ok"})),
1288        ] {
1289            let mut message = extra;
1290            message["role"] = json!(role);
1291            message["tools"] = tools.clone();
1292            let request = Request::new(json!([message, {"role": "user", "content": "Go"}]));
1293
1294            let error = fmt().render(&request).unwrap_err();
1295            assert_eq!(
1296                invalid_request_message(&error),
1297                format!(
1298                    "`tools` is only accepted on system or developer messages, not on role {role}"
1299                ),
1300                "role={role}"
1301            );
1302        }
1303    }
1304
1305    #[test]
1306    fn rejects_unsupported_message_roles() {
1307        for role in ["function", "unknown"] {
1308            let request = Request::new(json!([{"role": role, "content": "ignored before"}]));
1309
1310            let error = fmt().render(&request).unwrap_err();
1311
1312            assert!(matches!(
1313                error.downcast_ref::<PromptRenderError>(),
1314                Some(PromptRenderError::InvalidRequest(message))
1315                    if message == &format!("Kimi K3 does not support message role {role:?}")
1316            ));
1317        }
1318    }
1319
1320    #[test]
1321    fn rejects_messages_without_a_string_role() {
1322        for messages in [json!([{"content": "missing"}]), json!([{"role": 7}])] {
1323            let request = Request::new(messages);
1324
1325            let error = fmt().render(&request).unwrap_err();
1326
1327            assert!(matches!(
1328                error.downcast_ref::<PromptRenderError>(),
1329                Some(PromptRenderError::InvalidRequest(message))
1330                    if message == "Kimi K3 messages must contain a string role"
1331            ));
1332        }
1333    }
1334
1335    #[test]
1336    fn rejects_unsupported_thinking_effort_as_invalid_request() {
1337        let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1338        request.args.insert(
1339            "thinking_effort".to_string(),
1340            Value::String("medium".to_string()),
1341        );
1342
1343        let error = fmt().render(&request).unwrap_err();
1344        assert!(matches!(
1345            error.downcast_ref::<PromptRenderError>(),
1346            Some(PromptRenderError::InvalidRequest(message))
1347                if message.contains("thinking_effort=\"medium\"")
1348        ));
1349    }
1350
1351    // -- Kimi Partial Mode (prefix continuation) --
1352
1353    #[test]
1354    fn partial_assistant_renders_open_turn_in_place_of_generation_prompt() {
1355        let mut request = Request::new(json!([
1356            {"role": "user", "content": "Greet the customer"},
1357            {"role": "assistant", "content": "Dear customer, hello", "partial": true}
1358        ]));
1359        request
1360            .args
1361            .insert("thinking".to_string(), Value::Bool(false));
1362
1363        let rendered = fmt().render(&request).unwrap();
1364
1365        assert_eq!(
1366            rendered,
1367            concat!(
1368                "<|open|>message role=\"user\"<|sep|>Greet the customer",
1369                "<|close|>message<|sep|><|end_of_msg|>",
1370                "<|open|>message role=\"assistant\"<|sep|>",
1371                "<|open|>response<|sep|>Dear customer, hello"
1372            ),
1373            "the partial turn must stay open: no <|close|>response / <|close|>message / <|end_of_msg|>, \
1374             and no extra generation prompt after it"
1375        );
1376        for tool_calls in [Value::Null, json!([])] {
1377            request.messages[1]["tool_calls"] = tool_calls;
1378            assert_eq!(fmt().render(&request).unwrap(), rendered);
1379        }
1380    }
1381
1382    #[test]
1383    fn partial_assistant_ignores_add_generation_prompt_flag() {
1384        let mut request = Request::new(json!([
1385            {"role": "user", "content": "Go"},
1386            {"role": "assistant", "content": "prefix", "partial": true}
1387        ]));
1388        request
1389            .args
1390            .insert("thinking".to_string(), Value::Bool(false));
1391        request.add_generation_prompt = false;
1392
1393        let rendered = fmt().render(&request).unwrap();
1394
1395        assert!(rendered.ends_with("<|open|>response<|sep|>prefix"));
1396        assert_eq!(rendered.matches("role=\"assistant\"").count(), 1);
1397    }
1398
1399    #[test]
1400    fn partial_assistant_in_thinking_mode_closes_think_then_opens_response() {
1401        let mut request = Request::new(json!([
1402            {"role": "user", "content": "Go"},
1403            {
1404                "role": "assistant",
1405                "reasoning_content": "carried over reasoning",
1406                "content": "prefix",
1407                "partial": true
1408            }
1409        ]));
1410        request
1411            .args
1412            .insert("thinking".to_string(), Value::Bool(true));
1413
1414        let rendered = fmt().render(&request).unwrap();
1415
1416        assert!(rendered.ends_with(concat!(
1417            "<|open|>message role=\"assistant\"<|sep|>",
1418            "<|open|>think<|sep|>carried over reasoning<|close|>think<|sep|>",
1419            "<|open|>response<|sep|>prefix"
1420        )));
1421    }
1422
1423    #[test]
1424    fn partial_assistant_keeps_name_as_part_of_the_prefix() {
1425        let mut request = Request::new(json!([
1426            {"role": "user", "content": "Who are you?"},
1427            {"role": "assistant", "name": "Sherlock", "content": "Elementary", "partial": true}
1428        ]));
1429        request
1430            .args
1431            .insert("thinking".to_string(), Value::Bool(false));
1432
1433        let rendered = fmt().render(&request).unwrap();
1434
1435        assert!(rendered.ends_with(concat!(
1436            "<|open|>message role=\"assistant\" name=\"Sherlock\"<|sep|>",
1437            "<|open|>response<|sep|>Elementary"
1438        )));
1439    }
1440
1441    #[test]
1442    fn partial_assistant_follows_internal_system_messages() {
1443        // tool_choice / response_format hints are injected after history and
1444        // before the generation turn; a partial turn must not be split by them.
1445        let mut request = Request::new(json!([
1446            {"role": "user", "content": "Go"},
1447            {"role": "assistant", "content": "prefix", "partial": true}
1448        ]));
1449        request.tools = Some(json!([{
1450            "type": "function",
1451            "function": {"name": "lookup", "parameters": {"type": "object"}}
1452        }]));
1453        request.tool_choice = Some(json!("none"));
1454        request
1455            .args
1456            .insert("thinking".to_string(), Value::Bool(false));
1457
1458        let rendered = fmt().render(&request).unwrap();
1459
1460        let hint = rendered
1461            .find("tool_choice=none")
1462            .expect("tool-choice hint rendered");
1463        let turn = rendered
1464            .rfind("<|open|>message role=\"assistant\"<|sep|>")
1465            .expect("partial turn rendered");
1466        assert!(
1467            hint < turn,
1468            "internal system messages must precede the open partial turn"
1469        );
1470        assert!(rendered.ends_with("<|open|>response<|sep|>prefix"));
1471    }
1472
1473    #[test]
1474    fn partial_false_is_an_ordinary_assistant_turn() {
1475        let mut request = Request::new(json!([
1476            {"role": "user", "content": "Go"},
1477            {"role": "assistant", "content": "done", "partial": false}
1478        ]));
1479        request
1480            .args
1481            .insert("thinking".to_string(), Value::Bool(false));
1482
1483        let rendered = fmt().render(&request).unwrap();
1484
1485        assert!(rendered.contains(
1486            "<|open|>response<|sep|>done<|close|>response<|sep|><|close|>message<|sep|><|end_of_msg|>"
1487        ));
1488        assert!(
1489            rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>")
1490        );
1491    }
1492
1493    #[test]
1494    fn rejects_partial_on_a_non_final_message() {
1495        let request = Request::new(json!([
1496            {"role": "assistant", "content": "early", "partial": true},
1497            {"role": "user", "content": "Go"}
1498        ]));
1499
1500        let error = fmt().render(&request).unwrap_err();
1501
1502        assert!(matches!(
1503            error.downcast_ref::<PromptRenderError>(),
1504            Some(PromptRenderError::InvalidRequest(message))
1505                if message == "Kimi K3 `partial` is only supported on the final message"
1506        ));
1507    }
1508
1509    #[test]
1510    fn rejects_partial_on_a_non_assistant_message() {
1511        let request = Request::new(json!([
1512            {"role": "user", "content": "Go", "partial": false}
1513        ]));
1514        let error = fmt().render(&request).unwrap_err();
1515        assert_eq!(
1516            invalid_request_message(&error),
1517            "Kimi K3 `partial` is only supported on an assistant message"
1518        );
1519    }
1520
1521    #[test]
1522    fn rejects_non_boolean_partial() {
1523        let request = Request::new(json!([
1524            {"role": "assistant", "content": "done", "partial": "true"}
1525        ]));
1526        let error = fmt().render(&request).unwrap_err();
1527        assert_eq!(
1528            invalid_request_message(&error),
1529            "Kimi K3 `partial` must be a boolean"
1530        );
1531    }
1532
1533    #[test]
1534    fn null_partial_is_equivalent_to_absent() {
1535        let mut request = Request::new(json!([
1536            {"role": "user", "content": "Go"}
1537        ]));
1538        let expected = fmt().render(&request).unwrap();
1539        request.messages[0]["partial"] = Value::Null;
1540        assert_eq!(fmt().render(&request).unwrap(), expected);
1541    }
1542
1543    // -- Dynamic tool system messages: content XOR tools --
1544
1545    fn invalid_request_message(error: &anyhow::Error) -> &str {
1546        match error.downcast_ref::<PromptRenderError>() {
1547            Some(PromptRenderError::InvalidRequest(message)) => message,
1548            other => panic!("expected InvalidRequest, got {other:?}"),
1549        }
1550    }
1551
1552    #[test]
1553    fn rejects_system_message_with_both_content_and_tools() {
1554        let request = Request::new(json!([
1555            {
1556                "role": "system",
1557                "content": "You are helpful",
1558                "tools": [{"type": "function", "function": {"name": "lookup"}}]
1559            },
1560            {"role": "user", "content": "Go"}
1561        ]));
1562
1563        let error = fmt().render(&request).unwrap_err();
1564        assert_eq!(
1565            invalid_request_message(&error),
1566            "Kimi K3 system messages carry either `content` or `tools`, not both"
1567        );
1568    }
1569
1570    #[test]
1571    fn rejects_system_message_tools_that_are_not_an_array() {
1572        let request = Request::new(json!([
1573            {"role": "system", "tools": {"name": "lookup"}},
1574            {"role": "user", "content": "Go"}
1575        ]));
1576
1577        let error = fmt().render(&request).unwrap_err();
1578        assert_eq!(
1579            invalid_request_message(&error),
1580            "Kimi K3 dynamic tool messages need `tools` to be an array"
1581        );
1582    }
1583
1584    /// Moonshot's official dynamic-tools verifier sends `"content": ""`
1585    /// alongside `tools` and expects success. Empty content is "omitted".
1586    #[test]
1587    fn accepts_dynamic_tools_with_empty_string_content() {
1588        for empty in [json!(""), json!([]), Value::Null] {
1589            let mut request = Request::new(json!([
1590                {"role": "user", "content": "Start"},
1591                {
1592                    "role": "system",
1593                    "content": empty,
1594                    "tools": [{"type": "function", "function": {"name": "lookup"}}]
1595                },
1596                {"role": "user", "content": "Go"}
1597            ]));
1598            request
1599                .args
1600                .insert("thinking".to_string(), Value::Bool(false));
1601
1602            let rendered = fmt()
1603                .render(&request)
1604                .unwrap_or_else(|e| panic!("content={empty}: {e}"));
1605            assert!(
1606                rendered.contains("## New Tools Available"),
1607                "content={empty}"
1608            );
1609            assert!(
1610                !rendered.contains("<|open|>message role=\"system\"<|sep|><|close|>message"),
1611                "content={empty}: must not emit an empty system turn"
1612            );
1613        }
1614    }
1615
1616    #[test]
1617    fn rejects_malformed_dynamic_tool_entries() {
1618        let long_name = "a".repeat(257);
1619        for (entry, needle) in [
1620            (json!("lookup"), "must be JSON objects"),
1621            (
1622                json!({"parameters": {"type": "object"}}),
1623                "need a string `name`",
1624            ),
1625            (
1626                json!({"type": "web_search", "function": {"name": "lookup"}}),
1627                "type=\"function\"",
1628            ),
1629            (
1630                json!({"function": {"name": "lookup"}}),
1631                "need type=\"function\"",
1632            ),
1633            (
1634                json!({"type": "function", "name": "lookup"}),
1635                "need a `function` object",
1636            ),
1637            (json!({"name": ""}), "must match"),
1638            (json!({"name": "1bad_name"}), "must match"),
1639            (json!({"name": "bad@name"}), "must match"),
1640            (json!({"name": long_name}), "maximum is 256"),
1641        ] {
1642            let request = Request::new(json!([
1643                {"role": "system", "tools": [entry]},
1644                {"role": "user", "content": "Go"}
1645            ]));
1646            let error = fmt().render(&request).unwrap_err();
1647            assert!(
1648                invalid_request_message(&error).contains(needle),
1649                "entry={entry}: {}",
1650                invalid_request_message(&error)
1651            );
1652        }
1653    }
1654
1655    #[test]
1656    fn rejects_duplicate_tool_names_across_declarations() {
1657        let mut request = Request::new(json!([
1658            {"role": "system", "tools": [{"name": "lookup"}]},
1659            {"role": "user", "content": "Go"}
1660        ]));
1661        request.tools = Some(json!([{
1662            "type": "function",
1663            "function": {"name": "lookup", "parameters": {"type": "object"}}
1664        }]));
1665        let error = fmt().render(&request).unwrap_err();
1666        assert!(invalid_request_message(&error).contains("declared more than once"));
1667
1668        request.messages[0]["role"] = json!("developer");
1669        let error = fmt().render(&request).unwrap_err();
1670        assert!(invalid_request_message(&error).contains("declared more than once"));
1671
1672        let mut request = Request::new(json!([{"role": "user", "content": "Go"}]));
1673        request.tools = Some(json!([
1674            {"type": "function", "function": {"name": "lookup"}},
1675            {"type": "function", "function": {"name": "lookup"}}
1676        ]));
1677        let error = fmt().render(&request).unwrap_err();
1678        assert!(invalid_request_message(&error).contains("more than once in `tools`"));
1679
1680        let max_name = "a".repeat(256);
1681        let request = Request::new(json!([
1682            {"role": "system", "tools": [
1683                {"name": "_private-tool_2"},
1684                {"type": "function", "function": {"name": max_name}}
1685            ]},
1686            {"role": "user", "content": "Go"}
1687        ]));
1688        fmt().render(&request).unwrap();
1689
1690        let mut request = Request::new(json!([
1691            {"role": "system", "tools": [{"name": "lookup"}, {"type": "function", "function": {"name": "search"}}]},
1692            {"role": "user", "content": "Go"}
1693        ]));
1694        request.tools = Some(json!([{
1695            "type": "function",
1696            "function": {"name": "add", "parameters": {"type": "object"}}
1697        }]));
1698        fmt().render(&request).unwrap();
1699    }
1700
1701    #[test]
1702    fn empty_tools_list_is_an_ordinary_system_message() {
1703        let mut request = Request::new(json!([
1704            {"role": "system", "content": "You are helpful", "tools": []},
1705            {"role": "user", "content": "Go"}
1706        ]));
1707        request
1708            .args
1709            .insert("thinking".to_string(), Value::Bool(false));
1710
1711        let rendered = fmt().render(&request).unwrap();
1712        assert!(rendered.contains("<|open|>message role=\"system\"<|sep|>You are helpful"));
1713        assert!(!rendered.contains("## New Tools Available"));
1714
1715        let request = Request::new(json!([
1716            {"role": "system", "tools": []},
1717            {"role": "user", "content": "Go"}
1718        ]));
1719        let error = fmt().render(&request).unwrap_err();
1720        assert_eq!(
1721            invalid_request_message(&error),
1722            "Kimi K3 system messages need `content` or `tools`"
1723        );
1724    }
1725
1726    #[test]
1727    fn rejects_system_message_with_neither_content_nor_tools() {
1728        let request = Request::new(json!([
1729            {"role": "system"},
1730            {"role": "user", "content": "Go"}
1731        ]));
1732
1733        let error = fmt().render(&request).unwrap_err();
1734        assert_eq!(
1735            invalid_request_message(&error),
1736            "Kimi K3 system messages need `content` or `tools`"
1737        );
1738    }
1739
1740    // -- Typed request path: JSON -> CreateChatCompletionRequest -> renderer --
1741    //
1742    // The raw-JSON `Request` above bypasses protocol deserialization. These
1743    // tests go through `dynamo_protocols::types::CreateChatCompletionRequest`
1744    // and its default `OAIChatLikeRequest` impl, which is what an HTTP frontend
1745    // actually hands to the formatter.
1746
1747    fn typed(body: Value) -> dynamo_protocols::types::CreateChatCompletionRequest {
1748        serde_json::from_value(body).expect("request deserializes")
1749    }
1750
1751    #[test]
1752    fn typed_request_rejects_invalid_top_level_tool_name() {
1753        let request = typed(json!({
1754            "model": "kimi-k3",
1755            "messages": [{"role": "user", "content": "Look it up"}],
1756            "tools": [{
1757                "type": "function",
1758                "function": {"name": "bad@name", "parameters": {"type": "object"}}
1759            }]
1760        }));
1761
1762        let error = fmt().render(&request).unwrap_err();
1763        assert_eq!(
1764            invalid_request_message(&error),
1765            "Kimi K3 tool name \"bad@name\" must match [A-Za-z_][A-Za-z0-9_-]*"
1766        );
1767    }
1768
1769    #[test]
1770    fn typed_request_renders_dynamic_tools_and_final_partial_end_to_end() {
1771        let request = typed(json!({
1772            "model": "kimi-k3",
1773            "messages": [
1774                {"role": "system", "tools": [{
1775                    "type": "function",
1776                    "function": {"name": "lookup", "parameters": {"type": "object"}}
1777                }]},
1778                {"role": "user", "content": "Look it up"},
1779                {"role": "assistant", "content": "Looking", "partial": true}
1780            ]
1781        }));
1782
1783        let rendered = fmt().render(&request).unwrap();
1784
1785        assert!(rendered.contains("## New Tools Available"));
1786        assert!(rendered.contains("\"lookup\""));
1787        assert!(
1788            rendered.ends_with("<|open|>response<|sep|>Looking"),
1789            "partial turn must stay open, got {rendered:?}"
1790        );
1791    }
1792
1793    #[test]
1794    fn typed_request_preserves_content_and_tools_for_renderer_conflict_check() {
1795        let request = typed(json!({
1796            "model": "kimi-k3",
1797            "messages": [
1798                {
1799                    "role": "system",
1800                    "content": "You are helpful",
1801                    "tools": [{"type": "function", "function": {"name": "lookup"}}]
1802                },
1803                {"role": "user", "content": "Go"}
1804            ]
1805        }));
1806
1807        let system = serde_json::to_value(&request.messages[0]).unwrap();
1808        assert_eq!(system["content"], json!("You are helpful"));
1809        assert_eq!(system["tools"][0]["function"]["name"], json!("lookup"));
1810
1811        let error = fmt().render(&request).unwrap_err();
1812        assert_eq!(
1813            invalid_request_message(&error),
1814            "Kimi K3 system messages carry either `content` or `tools`, not both"
1815        );
1816    }
1817
1818    #[test]
1819    fn rejects_partial_assistant_with_tool_calls() {
1820        let tool_call = json!({
1821            "id": "call_1",
1822            "type": "function",
1823            "function": {"name": "lookup", "arguments": "{}"}
1824        });
1825        for tool_calls in [json!([tool_call.clone()]), tool_call] {
1826            let request = Request::new(json!([
1827                {"role": "user", "content": "Go"},
1828                {
1829                    "role": "assistant",
1830                    "content": "prefix",
1831                    "partial": true,
1832                    "tool_calls": tool_calls
1833                }
1834            ]));
1835            let error = fmt().render(&request).unwrap_err();
1836            assert_eq!(
1837                invalid_request_message(&error),
1838                "Kimi K3 partial assistant messages cannot carry tool_calls",
1839                "tool_calls={tool_calls}"
1840            );
1841        }
1842    }
1843
1844    #[test]
1845    fn rejects_tools_on_final_partial_assistant_raw_path() {
1846        let request = Request::new(json!([
1847            {"role": "user", "content": "Go"},
1848            {
1849                "role": "assistant",
1850                "content": "prefix",
1851                "partial": true,
1852                "tools": [{"type": "function", "function": {"name": "lookup"}}]
1853            }
1854        ]));
1855
1856        let error = fmt().render(&request).unwrap_err();
1857
1858        assert!(matches!(
1859            error.downcast_ref::<PromptRenderError>(),
1860            Some(PromptRenderError::InvalidRequest(message))
1861                if message == "`tools` is only accepted on system or developer messages, not on role assistant"
1862        ));
1863    }
1864
1865    #[test]
1866    fn named_tool_choice_forces_tool_and_disables_thinking() {
1867        let mut request = Request::new(json!([
1868            {"role": "user", "content": "What did you do before?"},
1869            {
1870                "role": "assistant",
1871                "reasoning_content": "historical hidden reasoning",
1872                "content": "I answered the earlier question."
1873            },
1874            {"role": "user", "content": "Calculate"}
1875        ]));
1876        request.tools = Some(json!([{
1877            "type": "function",
1878            "function": {
1879                "name": "add_numbers",
1880                "parameters": {
1881                    "type": "object",
1882                    "properties": {
1883                        "a": {"type": "integer"},
1884                        "b": {"type": "integer"}
1885                    },
1886                    "required": ["a", "b"]
1887                }
1888            }
1889        }]));
1890        request.tool_choice = Some(json!({
1891            "type": "function",
1892            "function": {"name": "add_numbers"}
1893        }));
1894        request
1895            .args
1896            .insert("thinking".to_string(), Value::Bool(true));
1897
1898        let rendered = fmt().render(&request).unwrap();
1899        assert!(rendered.contains("The system is invoked with `tool_choice=specified`."));
1900        assert!(rendered.contains("MUST call the tool `add_numbers`"));
1901        assert!(
1902            rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>"),
1903            "named tool choice must use K3's non-thinking generation prefix"
1904        );
1905        assert!(
1906            !rendered.contains("<|open|>think<|sep|>"),
1907            "named tool choice must override thinking=true"
1908        );
1909        assert!(
1910            !rendered.contains("historical hidden reasoning"),
1911            "named tool choice must also suppress preserved thinking history"
1912        );
1913    }
1914
1915    #[test]
1916    fn named_tool_choice_accepts_a_dynamic_system_tool() {
1917        for lookup in [
1918            json!({"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}),
1919            json!({"name": "lookup", "parameters": {"type": "object"}}),
1920        ] {
1921            let mut request = Request::new(json!([
1922                {"role": "user", "content": "Start"},
1923                {"role": "system", "tools": [lookup]},
1924                {"role": "user", "content": "Look this up"}
1925            ]));
1926            request.tool_choice = Some(json!({
1927                "type": "function",
1928                "function": {"name": "lookup"}
1929            }));
1930
1931            let rendered = fmt().render(&request).unwrap();
1932            assert!(rendered.contains("## New Tools Available"));
1933            assert!(rendered.contains("MUST call the tool `lookup`"));
1934            assert!(
1935                request.tools.is_none(),
1936                "dynamic tools must not be folded into the top-level list"
1937            );
1938        }
1939    }
1940
1941    #[test]
1942    fn named_tool_choice_still_rejects_a_tool_absent_from_dynamic_tools() {
1943        let mut request = Request::new(json!([
1944            {"role": "system", "tools": [{"name": "lookup"}]},
1945            {"role": "user", "content": "Weather?"}
1946        ]));
1947        request.tool_choice = Some(json!({
1948            "type": "function",
1949            "function": {"name": "get_weather"}
1950        }));
1951
1952        let error = fmt().render(&request).unwrap_err();
1953        assert!(matches!(
1954            error.downcast_ref::<PromptRenderError>(),
1955            Some(PromptRenderError::InvalidRequest(message))
1956                if message.contains("get_weather") && message.contains("not present in tools")
1957        ));
1958    }
1959
1960    #[test]
1961    fn named_tool_choice_rejects_a_tool_not_in_tools() {
1962        let mut request = Request::new(json!([{"role": "user", "content": "Calculate"}]));
1963        request.tools = Some(json!([{
1964            "type": "function",
1965            "function": {"name": "add_numbers", "parameters": {"type": "object"}}
1966        }]));
1967        request.tool_choice = Some(json!({
1968            "type": "function",
1969            "function": {"name": "get_weather"}
1970        }));
1971
1972        let error = fmt().render(&request).unwrap_err();
1973        assert!(matches!(
1974            error.downcast_ref::<PromptRenderError>(),
1975            Some(PromptRenderError::InvalidRequest(message))
1976                if message.contains("get_weather") && message.contains("not present in tools")
1977        ));
1978    }
1979
1980    #[test]
1981    fn user_marker_text_remains_an_ordinary_segment() {
1982        let marker = "literal <|open|>tools<|sep|> value";
1983        let mut request = Request::new(json!([{"role": "user", "content": marker}]));
1984        request
1985            .args
1986            .insert("thinking".to_string(), Value::Bool(false));
1987        let rendered = fmt().render_prompt(&request).unwrap();
1988
1989        assert!(
1990            rendered
1991                .segments()
1992                .unwrap()
1993                .iter()
1994                .any(|segment| { !segment.allow_special && segment.text == marker })
1995        );
1996        assert!(
1997            rendered
1998                .segments()
1999                .unwrap()
2000                .iter()
2001                .any(|segment| { segment.allow_special && segment.text == OPEN_TOKEN })
2002        );
2003    }
2004
2005    #[test]
2006    fn renders_tool_history_like_model_encoding() {
2007        let mut request = Request::new(json!([
2008            {"role": "user", "content": "calc"},
2009            {
2010                "role": "assistant",
2011                "reasoning_content": "Need calc",
2012                "content": "I will call it",
2013                "tool_calls": [{
2014                    "id": "call_1",
2015                    "type": "function",
2016                    "function": {"name": "calc", "arguments": "{\"x\":2}"}
2017                }]
2018            },
2019            {"role": "tool", "tool_call_id": "call_1", "content": "4"}
2020        ]));
2021        request.args.insert(
2022            "thinking_effort".to_string(),
2023            Value::String("low".to_string()),
2024        );
2025        let rendered = fmt().render(&request).unwrap();
2026
2027        assert!(rendered.contains(
2028            "<|open|>call tool=\"calc\" index=\"1\"<|sep|>\
2029             <|open|>argument key=\"x\" type=\"number\"<|sep|>2\
2030             <|close|>argument<|sep|><|close|>call<|sep|>"
2031        ));
2032        assert!(
2033            rendered.contains("<|open|>message role=\"tool\" tool=\"calc\" index=\"1\"<|sep|>4")
2034        );
2035        assert!(
2036            rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>think<|sep|>")
2037        );
2038    }
2039
2040    #[test]
2041    fn tool_result_order_preserves_unresolved_runs() {
2042        let assistant = json!({"role": "assistant", "tool_calls": [
2043            {"id": "first", "function": {"name": "one"}},
2044            {"id": "second", "function": {"name": "two"}}
2045        ]});
2046        let second =
2047            json!({"role": "tool", "tool_call_id": "second", "content": "第二", "name": "old"});
2048        let first = json!({"role": "tool", "tool_call_id": "first", "content": "第一"});
2049        let sorted =
2050            normalize_tool_result_messages(vec![assistant.clone(), second.clone(), first]).unwrap();
2051        assert_eq!(sorted[1]["content"], "第一");
2052        assert_eq!(sorted[1]["tool"], "one");
2053        assert_eq!(sorted[2]["content"], "第二");
2054        assert_eq!(sorted[2]["tool"], "two");
2055        assert_eq!(sorted[2]["name"], "two");
2056
2057        let unresolved = vec![
2058            assistant,
2059            second,
2060            json!({"role": "tool", "tool_call_id": "unknown", "content": "keep order"}),
2061        ];
2062        assert_eq!(
2063            normalize_tool_result_messages(unresolved.clone()).unwrap(),
2064            unresolved
2065        );
2066    }
2067
2068    #[test]
2069    fn thinking_history_renders_an_empty_think_channel() {
2070        let request = Request::new(json!([
2071            {"role": "user", "content": "question"},
2072            {"role": "assistant", "content": "answer"},
2073            {"role": "user", "content": "follow-up"}
2074        ]));
2075
2076        let rendered = fmt().render(&request).unwrap();
2077
2078        assert!(rendered.contains(concat!(
2079            "<|open|>message role=\"assistant\"<|sep|>",
2080            "<|open|>think<|sep|><|close|>think<|sep|>",
2081            "<|open|>response<|sep|>answer<|close|>response<|sep|>"
2082        )));
2083    }
2084
2085    #[test]
2086    fn non_thinking_history_omits_preserved_reasoning() {
2087        let mut request = Request::new(json!([
2088            {"role": "user", "content": "question"},
2089            {
2090                "role": "assistant",
2091                "reasoning_content": "hidden reasoning",
2092                "content": "answer"
2093            },
2094            {"role": "user", "content": "follow-up"}
2095        ]));
2096        request
2097            .args
2098            .insert("thinking".to_string(), Value::Bool(false));
2099
2100        let rendered = fmt().render(&request).unwrap();
2101
2102        assert!(!rendered.contains("hidden reasoning"));
2103        assert!(!rendered.contains("<|open|>think<|sep|>"));
2104        assert!(rendered.contains(concat!(
2105            "<|open|>message role=\"assistant\"<|sep|>",
2106            "<|open|>response<|sep|>answer<|close|>response<|sep|>"
2107        )));
2108    }
2109
2110    #[test]
2111    fn tools_are_deep_sorted_before_declaration() {
2112        let mut request = Request::new(json!([{"role": "user", "content": "Weather?"}]));
2113        request
2114            .args
2115            .insert("thinking".to_string(), Value::Bool(false));
2116        request.tools = Some(json!([{
2117            "type": "function",
2118            "function": {
2119                "parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
2120                "name": "weather",
2121                "description": "Get weather"
2122            }
2123        }]));
2124        let rendered = fmt().render(&request).unwrap();
2125        assert!(rendered.contains(concat!(
2126            "[{\"function\":{\"description\":\"Get weather\",",
2127            "\"name\":\"weather\",\"parameters\":{\"properties\":",
2128            "{\"city\":{\"type\":\"string\"}},\"type\":\"object\"}},",
2129            "\"type\":\"function\"}]"
2130        )));
2131    }
2132
2133    #[test]
2134    fn assistant_history_matches_python_json_spacing_and_reasoning_fallback() {
2135        let request = Request::new(json!([{
2136            "role": "assistant",
2137            "reasoning_content": "",
2138            "reasoning": "fallback",
2139            "content": null,
2140            "tool_calls": [{
2141                "type": "function",
2142                "function": {
2143                    "name": "run",
2144                    "arguments": {
2145                        "opts": {"a": 1, "b": [true, false]}
2146                    }
2147                }
2148            }]
2149        }]));
2150        let rendered = fmt().render(&request).unwrap();
2151
2152        assert!(rendered.contains("<|open|>think<|sep|>fallback<|close|>think<|sep|>"));
2153        assert!(rendered.contains(concat!(
2154            "<|open|>argument key=\"opts\" type=\"object\"<|sep|>",
2155            "{\"a\": 1, \"b\": [true, false]}",
2156            "<|close|>argument<|sep|>"
2157        )));
2158    }
2159}