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