Skip to main content

dynamo_renderer/
kimi_k3.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native Kimi K3 XTML prompt rendering.
5//!
6//! K3 does not ship a Jinja chat template. Its model-side `encoding_k3.py`
7//! emits a sequence of segments where protocol markers are encoded with
8//! tiktoken special IDs and message/tool data is encoded as ordinary text.
9//! Keeping that distinction is required both for model parity and to prevent a
10//! literal marker in user content from becoming prompt structure.
11
12use std::collections::HashMap;
13
14use anyhow::{Context, Result, bail};
15use serde_json::{Map, Value};
16
17use crate::{
18    OAIChatLikeRequest, OAIPromptFormatter, PromptRenderError, RenderedPrompt, RenderedSegment,
19    thinking_bool_from_args,
20};
21
22const OPEN_TOKEN: &str = "<|open|>";
23const CLOSE_TOKEN: &str = "<|close|>";
24const SEP_TOKEN: &str = "<|sep|>";
25const END_OF_MSG_TOKEN: &str = "<|end_of_msg|>";
26/// The one token this renderer emits per image.
27///
28/// This is the canonical frontend contract: exactly one `<|media_pad|>` per
29/// image, for every engine. It is a registered special token in the K3
30/// tokenizer (`config.json`'s `media_placeholder_token_id`), so it encodes to
31/// a single id and stays one id no matter what surrounds it.
32///
33/// The checkpoint's other spelling, `<|kimi_image_placeholder|>`, is a plain
34/// string that is *not* in the vocabulary — it BPE-shatters into several ids
35/// whose boundaries depend on neighbouring text. Engines that want that form
36/// (vLLM) convert from the pad on the worker side, where a single known id is
37/// a reliable thing to substitute; matching a shattered string is not.
38///
39/// Equivalent to calling the checkpoint's own
40/// `encoding_k3.build_chat_segments(image_prompts=["<|media_pad|>"] * n)` —
41/// `image_prompts` is the model author's hook for exactly this choice, and
42/// `<|kimi_image_placeholder|>` is only its `None` fallback.
43const MEDIA_PAD: &str = "<|media_pad|>";
44const VALID_THINKING_EFFORTS: &[&str] = &["low", "high", "max"];
45
46#[derive(Debug, Clone)]
47pub struct KimiK3Formatter {
48    exclude_tools_when_tool_choice_none: bool,
49}
50
51impl KimiK3Formatter {
52    pub fn new(exclude_tools_when_tool_choice_none: bool) -> Self {
53        Self {
54            exclude_tools_when_tool_choice_none,
55        }
56    }
57
58    fn build_segments(&self, req: &dyn OAIChatLikeRequest) -> Result<Vec<RenderedSegment>> {
59        let messages = json_value(req.messages()).context("Failed to convert K3 messages")?;
60        let messages = messages
61            .as_array()
62            .context("Kimi K3 messages must be an array")?;
63        let messages = normalize_tool_result_messages(messages)?;
64
65        let tool_choice = req.tool_choice().map(json_value).transpose()?;
66        let (tool_choice_kind, named_tool) = resolve_tool_choice(tool_choice.as_ref())?;
67        let mut tools = req.tools().map(json_value).transpose()?;
68        if let Some(named_tool) = named_tool
69            && !tools
70                .as_ref()
71                .is_some_and(|tools| contains_tool(tools, named_tool))
72        {
73            return Err(PromptRenderError::invalid_request(format!(
74                "tool named {named_tool:?} in tool_choice is not present in tools"
75            ))
76            .into());
77        }
78        if self.exclude_tools_when_tool_choice_none && tool_choice_kind == Some("none") {
79            tools = None;
80        }
81        let tools = tools.map(deep_sort);
82
83        let args = req.chat_template_args();
84        // Moonshot's K3 API defines named tool choice as incompatible with
85        // thinking. Make the public function-object form work without requiring
86        // clients to know K3-specific chat-template arguments.
87        let thinking = named_tool.is_none() && thinking_bool_from_args(args).unwrap_or(true);
88        let thinking_effort = resolve_thinking_effort(args);
89        if thinking && !VALID_THINKING_EFFORTS.contains(&thinking_effort.as_str()) {
90            return Err(PromptRenderError::invalid_request(format!(
91                "Unsupported Kimi K3 thinking_effort={thinking_effort:?}; supported values are low, high, and max"
92            ))
93            .into());
94        }
95
96        let response_format = req.response_format().map(json_value).transpose()?;
97        build_chat_segments(
98            &messages,
99            tools.as_ref(),
100            tool_choice_kind,
101            named_tool,
102            response_format.as_ref(),
103            req.should_add_generation_prompt(),
104            thinking,
105            thinking_effort.as_str(),
106        )
107    }
108}
109
110impl OAIPromptFormatter for KimiK3Formatter {
111    fn supports_add_generation_prompt(&self) -> bool {
112        true
113    }
114
115    fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
116        Ok(RenderedPrompt::segmented(self.build_segments(req)?).into_text())
117    }
118
119    fn render_prompt(&self, req: &dyn OAIChatLikeRequest) -> Result<RenderedPrompt> {
120        Ok(RenderedPrompt::segmented(self.build_segments(req)?))
121    }
122}
123
124fn json_value(value: minijinja::value::Value) -> Result<Value> {
125    serde_json::to_value(&value).context("Failed to convert template value to JSON")
126}
127
128fn resolve_tool_choice(tool_choice: Option<&Value>) -> Result<(Option<&str>, Option<&str>)> {
129    match tool_choice {
130        Some(Value::String(kind)) => Ok((Some(kind.as_str()), None)),
131        Some(Value::Object(choice)) => {
132            if choice.get("type").and_then(Value::as_str) != Some("function") {
133                return Err(PromptRenderError::invalid_request(
134                    "Kimi K3 named tool_choice must have type=\"function\"",
135                )
136                .into());
137            }
138            // Chat Completions uses function.name. Responses API uses a
139            // top-level name and is normalized to the same internal request in
140            // Dynamo, but accepting both shapes keeps this renderer reusable.
141            let name = choice
142                .get("function")
143                .and_then(Value::as_object)
144                .and_then(|function| function.get("name"))
145                .or_else(|| choice.get("name"))
146                .and_then(Value::as_str)
147                .filter(|name| !name.is_empty())
148                .ok_or_else(|| {
149                    PromptRenderError::invalid_request(
150                        "Kimi K3 named tool_choice requires a non-empty function name",
151                    )
152                })?;
153            Ok((Some("specified"), Some(name)))
154        }
155        Some(Value::Null) | None => Ok((None, None)),
156        Some(other) => Err(anyhow::anyhow!(
157            "Unsupported Kimi K3 tool_choice value: {other}"
158        )),
159    }
160}
161
162fn contains_tool(tools: &Value, name: &str) -> bool {
163    tools.as_array().is_some_and(|tools| {
164        tools.iter().any(|tool| {
165            tool.get("function")
166                .and_then(Value::as_object)
167                .and_then(|function| function.get("name"))
168                .or_else(|| tool.get("name"))
169                .and_then(Value::as_str)
170                == Some(name)
171        })
172    })
173}
174
175fn resolve_thinking_effort(args: Option<&HashMap<String, Value>>) -> String {
176    args.and_then(|args| {
177        args.get("thinking_effort")
178            .or_else(|| args.get("reasoning_effort"))
179            .and_then(Value::as_str)
180    })
181    .unwrap_or("max")
182    .to_string()
183}
184
185fn push_segment(segments: &mut Vec<RenderedSegment>, text: impl Into<String>, allow_special: bool) {
186    let text = text.into();
187    if !text.is_empty() {
188        segments.push(RenderedSegment {
189            text,
190            allow_special,
191        });
192    }
193}
194
195fn control(segments: &mut Vec<RenderedSegment>, text: impl Into<String>) {
196    push_segment(segments, text, true);
197}
198
199fn text(segments: &mut Vec<RenderedSegment>, text: impl Into<String>) {
200    push_segment(segments, text, false);
201}
202
203fn escape_attr_value(value: impl std::fmt::Display) -> String {
204    value
205        .to_string()
206        .replace('&', "&amp;")
207        .replace('"', "&quot;")
208}
209
210fn open_tag(
211    segments: &mut Vec<RenderedSegment>,
212    tag: &str,
213    attrs: impl IntoIterator<Item = (String, String)>,
214) {
215    control(segments, OPEN_TOKEN);
216    text(segments, tag);
217    for (key, value) in attrs {
218        text(segments, format!(" {key}"));
219        text(segments, "=\"");
220        text(segments, escape_attr_value(value));
221        text(segments, "\"");
222    }
223    control(segments, SEP_TOKEN);
224}
225
226fn close_tag(segments: &mut Vec<RenderedSegment>, tag: &str) {
227    control(segments, CLOSE_TOKEN);
228    text(segments, tag);
229    control(segments, SEP_TOKEN);
230}
231
232fn end_of_msg(segments: &mut Vec<RenderedSegment>) {
233    control(segments, END_OF_MSG_TOKEN);
234}
235
236fn internal_system_message(segments: &mut Vec<RenderedSegment>, message_type: &str, body: &str) {
237    open_tag(
238        segments,
239        "message",
240        [
241            ("role".to_string(), "system".to_string()),
242            ("type".to_string(), message_type.to_string()),
243        ],
244    );
245    text(segments, body.trim());
246    close_tag(segments, "message");
247    end_of_msg(segments);
248}
249
250fn deep_sort(value: Value) -> Value {
251    match value {
252        Value::Object(map) => {
253            let mut entries: Vec<_> = map.into_iter().collect();
254            entries.sort_by(|(left, _), (right, _)| left.cmp(right));
255            Value::Object(
256                entries
257                    .into_iter()
258                    .map(|(key, value)| (key, deep_sort(value)))
259                    .collect(),
260            )
261        }
262        Value::Array(items) => Value::Array(items.into_iter().map(deep_sort).collect()),
263        other => other,
264    }
265}
266
267fn compact_json(value: &Value) -> Result<String> {
268    serde_json::to_string(value).context("Failed to serialize K3 JSON")
269}
270
271fn response_schema(response_format: &Value) -> Option<Value> {
272    let json_schema = response_format.get("json_schema")?;
273    if let Some(schema) = json_schema.get("schema") {
274        return Some(schema.clone());
275    }
276    if let Some(schema) = json_schema.get("json_schema") {
277        return Some(schema.clone());
278    }
279    Some(json_schema.clone())
280}
281
282fn value_as_body_text(value: &Value) -> Result<String> {
283    match value {
284        Value::String(value) => Ok(value.clone()),
285        Value::Array(values) if values.iter().all(Value::is_string) => Ok(values
286            .iter()
287            .filter_map(Value::as_str)
288            .filter(|value| !value.is_empty())
289            .collect::<Vec<_>>()
290            .join("\n")),
291        other => compact_json(other),
292    }
293}
294
295fn render_content_segments(
296    segments: &mut Vec<RenderedSegment>,
297    content: Option<&Value>,
298) -> Result<()> {
299    let Some(content) = content else {
300        return Ok(());
301    };
302    match content {
303        Value::Null => {}
304        Value::String(value) => text(segments, value),
305        Value::Array(parts) => {
306            for part in parts {
307                match part.get("type").and_then(Value::as_str) {
308                    Some("image" | "image_url") => control(segments, MEDIA_PAD),
309                    _ => {
310                        if let Some(part_text) = part.get("text") {
311                            text(segments, value_as_body_text(part_text)?);
312                        }
313                    }
314                }
315            }
316        }
317        other => text(segments, value_as_body_text(other)?),
318    }
319    Ok(())
320}
321
322fn render_role_message(
323    segments: &mut Vec<RenderedSegment>,
324    message: &Value,
325    role: &str,
326) -> Result<()> {
327    let mut attrs = vec![("role".to_string(), role.to_string())];
328    if let Some(name) = message
329        .get("name")
330        .and_then(Value::as_str)
331        .filter(|name| !name.is_empty())
332    {
333        attrs.push(("name".to_string(), name.to_string()));
334    }
335    open_tag(segments, "message", attrs);
336    render_content_segments(segments, message.get("content"))?;
337    close_tag(segments, "message");
338    end_of_msg(segments);
339    Ok(())
340}
341
342fn render_tool_declare(
343    segments: &mut Vec<RenderedSegment>,
344    tools: &Value,
345    dynamic: bool,
346) -> Result<()> {
347    let tools = compact_json(tools)?;
348    let body = if dynamic {
349        format!(
350            "## New Tools Available\n\
351             The system dynamically extends the toolset via lazy-loading.\n\
352             You have access to all existing and extended tools.\n\
353             Here are the specs for the extended tools.\n\n\
354             ```json\n{tools}\n```"
355        )
356    } else {
357        format!(
358            "# Tools\n\
359             Here are the available tools, described in JSONSchema.\n\n\
360             ```json\n{tools}\n```"
361        )
362    };
363    open_tag(
364        segments,
365        "message",
366        [
367            ("role".to_string(), "system".to_string()),
368            ("type".to_string(), "tool-declare".to_string()),
369        ],
370    );
371    text(segments, body);
372    close_tag(segments, "message");
373    end_of_msg(segments);
374    Ok(())
375}
376
377fn xtml_type(value: &Value) -> &'static str {
378    match value {
379        Value::Bool(_) => "boolean",
380        Value::Null => "null",
381        Value::Number(_) => "number",
382        Value::String(_) => "string",
383        Value::Object(_) => "object",
384        Value::Array(_) => "array",
385    }
386}
387
388fn xtml_value(value: &Value) -> Result<String> {
389    match value {
390        Value::String(value) => Ok(value.clone()),
391        // Python's `json.dumps(..., ensure_ascii=False)` uses `", "` and
392        // `": "` separators by default. Preserve that byte shape in prompt
393        // history; the compact form is used only for schemas/tool declarations.
394        other => python_default_json(other),
395    }
396}
397
398fn python_default_json(value: &Value) -> Result<String> {
399    let compact = compact_json(value)?;
400    let mut output = String::with_capacity(compact.len());
401    let mut in_string = false;
402    let mut escaped = false;
403    for ch in compact.chars() {
404        output.push(ch);
405        if in_string {
406            if escaped {
407                escaped = false;
408            } else if ch == '\\' {
409                escaped = true;
410            } else if ch == '"' {
411                in_string = false;
412            }
413        } else if ch == '"' {
414            in_string = true;
415        } else if matches!(ch, ',' | ':') {
416            output.push(' ');
417        }
418    }
419    Ok(output)
420}
421
422enum NormalizedArguments {
423    Object(Map<String, Value>),
424    JsonBlock(String),
425}
426
427fn normalize_arguments(arguments: Option<&Value>) -> Result<NormalizedArguments> {
428    let Some(arguments) = arguments else {
429        return Ok(NormalizedArguments::Object(Map::new()));
430    };
431    match arguments {
432        Value::Null => Ok(NormalizedArguments::Object(Map::new())),
433        Value::Object(arguments) => Ok(NormalizedArguments::Object(arguments.clone())),
434        Value::String(arguments) if arguments.trim().is_empty() => {
435            Ok(NormalizedArguments::Object(Map::new()))
436        }
437        Value::String(arguments) => match serde_json::from_str::<Value>(arguments) {
438            Ok(Value::Object(arguments)) => Ok(NormalizedArguments::Object(arguments)),
439            Ok(_) => bail!("Kimi K3 tool call arguments must be a JSON object"),
440            Err(_) => Ok(NormalizedArguments::JsonBlock(arguments.clone())),
441        },
442        _ => bail!("Kimi K3 tool call arguments must be an object or JSON object string"),
443    }
444}
445
446fn render_assistant_segments(
447    segments: &mut Vec<RenderedSegment>,
448    message: &Value,
449    thinking: bool,
450) -> Result<()> {
451    // Match encoding_k3.py: `reasoning_content` wins when truthy, otherwise
452    // fall back to the Responses-style `reasoning` alias.
453    let reasoning = message
454        .get("reasoning_content")
455        .filter(|value| match value {
456            Value::Null => false,
457            Value::Bool(value) => *value,
458            Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
459            Value::String(value) => !value.is_empty(),
460            Value::Array(value) => !value.is_empty(),
461            Value::Object(value) => !value.is_empty(),
462        })
463        .or_else(|| message.get("reasoning"))
464        .map(value_as_body_text)
465        .transpose()?;
466
467    // The think channel is structural in the latest K3 model encoding. Every
468    // historical assistant message carries it in thinking mode, even if its
469    // body is empty. Non-thinking mode drops both the channel and preserved
470    // reasoning content.
471    if thinking {
472        open_tag(segments, "think", []);
473        if let Some(reasoning) = reasoning.filter(|reasoning| !reasoning.trim().is_empty()) {
474            text(segments, reasoning);
475        }
476        close_tag(segments, "think");
477    }
478
479    open_tag(segments, "response", []);
480    render_content_segments(segments, message.get("content"))?;
481    close_tag(segments, "response");
482
483    let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else {
484        return Ok(());
485    };
486    if tool_calls.is_empty() {
487        return Ok(());
488    }
489
490    open_tag(segments, "tools", []);
491    for (position, tool_call) in tool_calls.iter().enumerate() {
492        let function = tool_call.get("function").unwrap_or(tool_call);
493        let name = function
494            .get("name")
495            .and_then(Value::as_str)
496            .context("Kimi K3 tool call is missing function.name")?;
497        open_tag(
498            segments,
499            "call",
500            [
501                ("tool".to_string(), name.to_string()),
502                ("index".to_string(), (position + 1).to_string()),
503            ],
504        );
505
506        match normalize_arguments(function.get("arguments"))? {
507            NormalizedArguments::JsonBlock(raw) => {
508                open_tag(
509                    segments,
510                    "json",
511                    [("type".to_string(), "object".to_string())],
512                );
513                text(segments, raw);
514                close_tag(segments, "json");
515            }
516            NormalizedArguments::Object(arguments) => {
517                for (key, value) in arguments {
518                    open_tag(
519                        segments,
520                        "argument",
521                        [
522                            ("key".to_string(), key),
523                            ("type".to_string(), xtml_type(&value).to_string()),
524                        ],
525                    );
526                    text(segments, xtml_value(&value)?);
527                    close_tag(segments, "argument");
528                }
529            }
530        }
531        close_tag(segments, "call");
532    }
533    close_tag(segments, "tools");
534    Ok(())
535}
536
537fn tool_call_index(tool_calls: Option<&Value>) -> HashMap<String, (usize, Option<String>)> {
538    let mut index = HashMap::new();
539    let Some(tool_calls) = tool_calls.and_then(Value::as_array) else {
540        return index;
541    };
542    for (position, tool_call) in tool_calls.iter().enumerate() {
543        let Some(id) = tool_call.get("id").and_then(Value::as_str) else {
544            continue;
545        };
546        let function = tool_call.get("function").unwrap_or(tool_call);
547        let name = function
548            .get("name")
549            .and_then(Value::as_str)
550            .map(str::to_string);
551        index.entry(id.to_string()).or_insert((position + 1, name));
552    }
553    index
554}
555
556fn normalize_tool_result_messages(messages: &[Value]) -> Result<Vec<Value>> {
557    let mut output = Vec::with_capacity(messages.len());
558    let mut current_index = HashMap::new();
559    let mut position = 0;
560
561    while position < messages.len() {
562        let message = &messages[position];
563        let role = message.get("role").and_then(Value::as_str);
564        if role == Some("assistant") {
565            current_index = tool_call_index(message.get("tool_calls"));
566            output.push(message.clone());
567            position += 1;
568            continue;
569        }
570        if role != Some("tool") {
571            output.push(message.clone());
572            position += 1;
573            continue;
574        }
575
576        let mut run: Vec<(Option<usize>, usize, Value, Option<String>)> = Vec::new();
577        let mut unresolved = false;
578        let mut offset = 0;
579        while position < messages.len()
580            && messages[position].get("role").and_then(Value::as_str) == Some("tool")
581        {
582            let tool_message = &messages[position];
583            let call_id = tool_message
584                .get("tool_call_id")
585                .or_else(|| tool_message.get("id"))
586                .and_then(Value::as_str);
587            let matched = call_id.and_then(|id| current_index.get(id));
588            if let Some((tool_position, name)) = matched {
589                run.push((
590                    Some(*tool_position),
591                    offset,
592                    tool_message.clone(),
593                    name.clone(),
594                ));
595            } else {
596                unresolved = true;
597                run.push((None, offset, tool_message.clone(), None));
598            }
599            offset += 1;
600            position += 1;
601        }
602
603        if unresolved {
604            output.extend(run.into_iter().map(|(_, _, message, _)| message));
605            continue;
606        }
607        run.sort_by_key(|(tool_position, offset, _, _)| (*tool_position, *offset));
608        for (_, _, mut message, name) in run {
609            if let (Some(name), Some(message)) = (name, message.as_object_mut()) {
610                message.insert("tool".to_string(), Value::String(name.clone()));
611                if message.contains_key("name") {
612                    message.insert("name".to_string(), Value::String(name));
613                }
614            }
615            output.push(message);
616        }
617    }
618    Ok(output)
619}
620
621#[allow(clippy::too_many_arguments)]
622fn build_chat_segments(
623    messages: &[Value],
624    tools: Option<&Value>,
625    tool_choice: Option<&str>,
626    named_tool: Option<&str>,
627    response_format: Option<&Value>,
628    add_generation_prompt: bool,
629    thinking: bool,
630    thinking_effort: &str,
631) -> Result<Vec<RenderedSegment>> {
632    let mut segments = Vec::new();
633    let mut previous_tool_calls: Option<&Value> = None;
634    let mut tool_index = 0usize;
635
636    if let Some(tools) = tools.filter(|tools| !tools.as_array().is_some_and(Vec::is_empty)) {
637        render_tool_declare(&mut segments, tools, false)?;
638    }
639
640    if thinking {
641        internal_system_message(
642            &mut segments,
643            "thinking-effort",
644            &format!(
645                "`thinking_effort` guides on how much to think in your thinking channel \
646                 (not including the response channel), supported values include `low`, \
647                 `medium`, `high`, and `max`.\nNow the system is invoked with \
648                 `thinking_effort={thinking_effort}`."
649            ),
650        );
651    }
652
653    for message in messages {
654        let role = message.get("role").and_then(Value::as_str).ok_or_else(|| {
655            PromptRenderError::invalid_request("Kimi K3 messages must contain a string role")
656        })?;
657        match role {
658            "system" | "developer"
659                if message.get("tools").is_some_and(|tools| {
660                    !tools.is_null() && !tools.as_array().is_some_and(Vec::is_empty)
661                }) =>
662            {
663                let dynamic_tools = deep_sort(message["tools"].clone());
664                render_tool_declare(&mut segments, &dynamic_tools, true)?;
665                if role == "developer"
666                    && message
667                        .get("content")
668                        .is_some_and(|content| !content.is_null())
669                {
670                    render_role_message(&mut segments, message, "system")?;
671                }
672            }
673            "user" | "system" | "developer" => {
674                let rendered_role = if role == "developer" { "system" } else { role };
675                render_role_message(&mut segments, message, rendered_role)?;
676            }
677            "assistant" => {
678                previous_tool_calls = message.get("tool_calls");
679                tool_index = 0;
680                let mut attrs = vec![("role".to_string(), "assistant".to_string())];
681                if let Some(name) = message
682                    .get("name")
683                    .and_then(Value::as_str)
684                    .filter(|name| !name.is_empty())
685                {
686                    attrs.push(("name".to_string(), name.to_string()));
687                }
688                open_tag(&mut segments, "message", attrs);
689                render_assistant_segments(&mut segments, message, thinking)?;
690                close_tag(&mut segments, "message");
691                end_of_msg(&mut segments);
692            }
693            "tool" => {
694                tool_index += 1;
695                let fallback_name = previous_tool_calls
696                    .and_then(Value::as_array)
697                    .and_then(|calls| calls.get(tool_index - 1))
698                    .map(|call| call.get("function").unwrap_or(call))
699                    .and_then(|function| function.get("name"))
700                    .and_then(Value::as_str);
701                let tool_name = message
702                    .get("tool")
703                    .or_else(|| message.get("name"))
704                    .and_then(Value::as_str)
705                    .or(fallback_name)
706                    .context(
707                        "Kimi K3 tool messages need a tool/name or a preceding assistant tool call",
708                    )?;
709                open_tag(
710                    &mut segments,
711                    "message",
712                    [
713                        ("role".to_string(), "tool".to_string()),
714                        ("tool".to_string(), tool_name.to_string()),
715                        ("index".to_string(), tool_index.to_string()),
716                    ],
717                );
718                render_content_segments(&mut segments, message.get("content"))?;
719                close_tag(&mut segments, "message");
720                end_of_msg(&mut segments);
721            }
722            unsupported => {
723                return Err(PromptRenderError::invalid_request(format!(
724                    "Kimi K3 does not support message role {unsupported:?}"
725                ))
726                .into());
727            }
728        }
729    }
730
731    match tool_choice {
732        Some("required") => internal_system_message(
733            &mut segments,
734            "tool-choice",
735            "The system is invoked with `tool_choice=required`.\n\
736             You MUST call tools in the next message.",
737        ),
738        Some("none") => internal_system_message(
739            &mut segments,
740            "tool-choice",
741            "The system is invoked with `tool_choice=none`.\n\
742             You MUST NOT call any tools in the next message.",
743        ),
744        Some("specified") => internal_system_message(
745            &mut segments,
746            "tool-choice",
747            &format!(
748                "The system is invoked with `tool_choice=specified`.\n\
749                 You MUST call the tool `{}` in the next message.",
750                named_tool.expect("specified tool_choice has a function name")
751            ),
752        ),
753        _ => {}
754    }
755
756    if let Some(response_format) = response_format {
757        match response_format.get("type").and_then(Value::as_str) {
758            Some("json_object") => internal_system_message(
759                &mut segments,
760                "response-format",
761                "The system is invoked with `response_format=json_object`.\n\
762                 Your response must be raw JSON data without markdown code blocks \
763                 (```json) or any additional formatting.",
764            ),
765            Some("json_schema") => {
766                let schema = response_schema(response_format)
767                    .map(deep_sort)
768                    .unwrap_or(Value::Null);
769                internal_system_message(
770                    &mut segments,
771                    "response-format",
772                    &format!(
773                        "The system is invoked with `response_format=json_schema`.\n\
774                         Your response must be raw JSON data without markdown code blocks \
775                         (```json) or any additional formatting.\n\
776                         The JSON data must match the following schema:\n\
777                         ```json\n{}\n```",
778                        compact_json(&schema)?
779                    ),
780                );
781            }
782            _ => {}
783        }
784    }
785
786    if add_generation_prompt {
787        open_tag(
788            &mut segments,
789            "message",
790            [("role".to_string(), "assistant".to_string())],
791        );
792        open_tag(
793            &mut segments,
794            if thinking { "think" } else { "response" },
795            [],
796        );
797    }
798
799    Ok(segments)
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use minijinja::value::Value as MiniValue;
806    use serde_json::json;
807
808    struct Request {
809        messages: Value,
810        tools: Option<Value>,
811        tool_choice: Option<Value>,
812        response_format: Option<Value>,
813        args: HashMap<String, Value>,
814        add_generation_prompt: bool,
815    }
816
817    impl Request {
818        fn new(messages: Value) -> Self {
819            Self {
820                messages,
821                tools: None,
822                tool_choice: None,
823                response_format: None,
824                args: HashMap::new(),
825                add_generation_prompt: true,
826            }
827        }
828    }
829
830    impl OAIChatLikeRequest for Request {
831        fn model(&self) -> String {
832            "kimi-k3".to_string()
833        }
834
835        fn messages(&self) -> MiniValue {
836            MiniValue::from_serialize(&self.messages)
837        }
838
839        fn tools(&self) -> Option<MiniValue> {
840            self.tools.as_ref().map(MiniValue::from_serialize)
841        }
842
843        fn tool_choice(&self) -> Option<MiniValue> {
844            self.tool_choice.as_ref().map(MiniValue::from_serialize)
845        }
846
847        fn response_format(&self) -> Option<MiniValue> {
848            self.response_format.as_ref().map(MiniValue::from_serialize)
849        }
850
851        fn should_add_generation_prompt(&self) -> bool {
852            self.add_generation_prompt
853        }
854
855        fn chat_template_args(&self) -> Option<&HashMap<String, Value>> {
856            Some(&self.args)
857        }
858    }
859
860    /// Default formatter: no worker declaration, so the checkpoint token.
861    fn fmt() -> KimiK3Formatter {
862        KimiK3Formatter::new(true)
863    }
864
865    /// One user message carrying a single image part.
866    fn image_request() -> Request {
867        let mut request = Request::new(json!([{
868            "role": "user",
869            "content": [{"type": "image_url", "image_url": {"url": "http://example.com/a.png"}}]
870        }]));
871        request
872            .args
873            .insert("thinking".to_string(), Value::Bool(false));
874        request
875    }
876
877    fn image_segments(formatter: &KimiK3Formatter, request: &Request) -> Vec<RenderedSegment> {
878        formatter
879            .render_prompt(request)
880            .unwrap()
881            .segments()
882            .expect("K3 always renders segmented prompts")
883            .to_vec()
884    }
885
886    #[test]
887    fn renders_one_media_pad_per_image() {
888        let segments = image_segments(&fmt(), &image_request());
889
890        let matches: Vec<_> = segments
891            .iter()
892            .filter(|segment| segment.text == MEDIA_PAD)
893            .collect();
894        assert_eq!(matches.len(), 1, "exactly one pad per image");
895        // The pad MUST stay special: it is a registered token, and only the
896        // special-aware encode path yields its single id.
897        assert!(matches[0].allow_special);
898        // The checkpoint's non-vocabulary spelling must never be emitted --
899        // the vLLM worker converts from the pad instead.
900        assert!(
901            !segments
902                .iter()
903                .any(|segment| segment.text.contains("kimi_image_placeholder")),
904        );
905    }
906
907    #[test]
908    fn image_token_cardinality_is_one_per_image() {
909        let mut request = Request::new(json!([{
910            "role": "user",
911            "content": [
912                {"type": "image_url", "image_url": {"url": "http://example.com/a.png"}},
913                {"type": "text", "text": "and"},
914                {"type": "image_url", "image_url": {"url": "http://example.com/b.png"}},
915                {"type": "text", "text": "compare them"}
916            ]
917        }]));
918        request
919            .args
920            .insert("thinking".to_string(), Value::Bool(false));
921
922        let segments = image_segments(&fmt(), &request);
923
924        assert_eq!(
925            segments
926                .iter()
927                .filter(|segment| segment.text == MEDIA_PAD)
928                .count(),
929            2
930        );
931        // Interleaved prose must stay ordinary text.
932        for body in ["and", "compare them"] {
933            assert!(
934                segments
935                    .iter()
936                    .any(|segment| segment.text == body && !segment.allow_special)
937            );
938        }
939    }
940
941    #[test]
942    fn user_text_spelling_the_pad_stays_ordinary() {
943        let body = "please describe <|media_pad|>";
944        let mut request = Request::new(json!([{"role": "user", "content": body}]));
945        request
946            .args
947            .insert("thinking".to_string(), Value::Bool(false));
948
949        let segments = image_segments(&fmt(), &request);
950
951        assert!(
952            segments
953                .iter()
954                .any(|segment| segment.text == body && !segment.allow_special),
955            "user content must never be promoted into prompt structure"
956        );
957    }
958
959    #[test]
960    fn renders_off_mode_like_model_encoding() {
961        let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
962        request
963            .args
964            .insert("thinking".to_string(), Value::Bool(false));
965        let rendered = fmt().render(&request).unwrap();
966        assert_eq!(
967            rendered,
968            concat!(
969                "<|open|>message role=\"user\"<|sep|>Hello",
970                "<|close|>message<|sep|><|end_of_msg|>",
971                "<|open|>message role=\"assistant\"<|sep|>",
972                "<|open|>response<|sep|>"
973            )
974        );
975    }
976
977    #[test]
978    fn renders_developer_messages_as_system() {
979        let mut request = Request::new(json!([
980            {"role": "developer", "content": "Follow this policy", "name": "policy"},
981            {"role": "user", "content": "Hello"}
982        ]));
983        request
984            .args
985            .insert("thinking".to_string(), Value::Bool(false));
986
987        let rendered = fmt().render(&request).unwrap();
988
989        assert!(
990            rendered.contains(
991                "<|open|>message role=\"system\" name=\"policy\"<|sep|>Follow this policy"
992            )
993        );
994        assert!(!rendered.contains("role=\"developer\""));
995        assert!(
996            rendered.find("Follow this policy").unwrap() < rendered.find("Hello").unwrap(),
997            "developer instructions must retain their position"
998        );
999    }
1000
1001    #[test]
1002    fn renders_developer_tools_and_content() {
1003        let mut request = Request::new(json!([
1004            {
1005                "role": "developer",
1006                "content": "Use the newly available tool",
1007                "tools": [{
1008                    "type": "function",
1009                    "function": {"name": "lookup", "parameters": {"type": "object"}}
1010                }]
1011            },
1012            {"role": "user", "content": "Look this up"}
1013        ]));
1014        request
1015            .args
1016            .insert("thinking".to_string(), Value::Bool(false));
1017
1018        let rendered = fmt().render(&request).unwrap();
1019
1020        assert!(rendered.contains("## New Tools Available"));
1021        assert!(
1022            rendered.contains("<|open|>message role=\"system\"<|sep|>Use the newly available tool")
1023        );
1024    }
1025
1026    #[test]
1027    fn rejects_unsupported_message_roles() {
1028        for role in ["function", "unknown"] {
1029            let request = Request::new(json!([{"role": role, "content": "ignored before"}]));
1030
1031            let error = fmt().render(&request).unwrap_err();
1032
1033            assert!(matches!(
1034                error.downcast_ref::<PromptRenderError>(),
1035                Some(PromptRenderError::InvalidRequest(message))
1036                    if message == &format!("Kimi K3 does not support message role {role:?}")
1037            ));
1038        }
1039    }
1040
1041    #[test]
1042    fn rejects_messages_without_a_string_role() {
1043        for messages in [json!([{"content": "missing"}]), json!([{"role": 7}])] {
1044            let request = Request::new(messages);
1045
1046            let error = fmt().render(&request).unwrap_err();
1047
1048            assert!(matches!(
1049                error.downcast_ref::<PromptRenderError>(),
1050                Some(PromptRenderError::InvalidRequest(message))
1051                    if message == "Kimi K3 messages must contain a string role"
1052            ));
1053        }
1054    }
1055
1056    #[test]
1057    fn rejects_unsupported_thinking_effort_as_invalid_request() {
1058        let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1059        request.args.insert(
1060            "thinking_effort".to_string(),
1061            Value::String("medium".to_string()),
1062        );
1063
1064        let error = fmt().render(&request).unwrap_err();
1065        assert!(matches!(
1066            error.downcast_ref::<PromptRenderError>(),
1067            Some(PromptRenderError::InvalidRequest(message))
1068                if message.contains("thinking_effort=\"medium\"")
1069        ));
1070    }
1071
1072    #[test]
1073    fn named_tool_choice_forces_tool_and_disables_thinking() {
1074        let mut request = Request::new(json!([
1075            {"role": "user", "content": "What did you do before?"},
1076            {
1077                "role": "assistant",
1078                "reasoning_content": "historical hidden reasoning",
1079                "content": "I answered the earlier question."
1080            },
1081            {"role": "user", "content": "Calculate"}
1082        ]));
1083        request.tools = Some(json!([{
1084            "type": "function",
1085            "function": {
1086                "name": "add_numbers",
1087                "parameters": {
1088                    "type": "object",
1089                    "properties": {
1090                        "a": {"type": "integer"},
1091                        "b": {"type": "integer"}
1092                    },
1093                    "required": ["a", "b"]
1094                }
1095            }
1096        }]));
1097        request.tool_choice = Some(json!({
1098            "type": "function",
1099            "function": {"name": "add_numbers"}
1100        }));
1101        request
1102            .args
1103            .insert("thinking".to_string(), Value::Bool(true));
1104
1105        let rendered = fmt().render(&request).unwrap();
1106        assert!(rendered.contains("The system is invoked with `tool_choice=specified`."));
1107        assert!(rendered.contains("MUST call the tool `add_numbers`"));
1108        assert!(
1109            rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>"),
1110            "named tool choice must use K3's non-thinking generation prefix"
1111        );
1112        assert!(
1113            !rendered.contains("<|open|>think<|sep|>"),
1114            "named tool choice must override thinking=true"
1115        );
1116        assert!(
1117            !rendered.contains("historical hidden reasoning"),
1118            "named tool choice must also suppress preserved thinking history"
1119        );
1120    }
1121
1122    #[test]
1123    fn named_tool_choice_rejects_a_tool_not_in_tools() {
1124        let mut request = Request::new(json!([{"role": "user", "content": "Calculate"}]));
1125        request.tools = Some(json!([{
1126            "type": "function",
1127            "function": {"name": "add_numbers", "parameters": {"type": "object"}}
1128        }]));
1129        request.tool_choice = Some(json!({
1130            "type": "function",
1131            "function": {"name": "get_weather"}
1132        }));
1133
1134        let error = fmt().render(&request).unwrap_err();
1135        assert!(matches!(
1136            error.downcast_ref::<PromptRenderError>(),
1137            Some(PromptRenderError::InvalidRequest(message))
1138                if message.contains("get_weather") && message.contains("not present in tools")
1139        ));
1140    }
1141
1142    #[test]
1143    fn user_marker_text_remains_an_ordinary_segment() {
1144        let marker = "literal <|open|>tools<|sep|> value";
1145        let mut request = Request::new(json!([{"role": "user", "content": marker}]));
1146        request
1147            .args
1148            .insert("thinking".to_string(), Value::Bool(false));
1149        let rendered = fmt().render_prompt(&request).unwrap();
1150
1151        assert!(
1152            rendered
1153                .segments()
1154                .unwrap()
1155                .iter()
1156                .any(|segment| { !segment.allow_special && segment.text == marker })
1157        );
1158        assert!(
1159            rendered
1160                .segments()
1161                .unwrap()
1162                .iter()
1163                .any(|segment| { segment.allow_special && segment.text == OPEN_TOKEN })
1164        );
1165    }
1166
1167    #[test]
1168    fn renders_tool_history_like_model_encoding() {
1169        let mut request = Request::new(json!([
1170            {"role": "user", "content": "calc"},
1171            {
1172                "role": "assistant",
1173                "reasoning_content": "Need calc",
1174                "content": "I will call it",
1175                "tool_calls": [{
1176                    "id": "call_1",
1177                    "type": "function",
1178                    "function": {"name": "calc", "arguments": "{\"x\":2}"}
1179                }]
1180            },
1181            {"role": "tool", "tool_call_id": "call_1", "content": "4"}
1182        ]));
1183        request.args.insert(
1184            "thinking_effort".to_string(),
1185            Value::String("low".to_string()),
1186        );
1187        let rendered = fmt().render(&request).unwrap();
1188
1189        assert!(rendered.contains(
1190            "<|open|>call tool=\"calc\" index=\"1\"<|sep|>\
1191             <|open|>argument key=\"x\" type=\"number\"<|sep|>2\
1192             <|close|>argument<|sep|><|close|>call<|sep|>"
1193        ));
1194        assert!(
1195            rendered.contains("<|open|>message role=\"tool\" tool=\"calc\" index=\"1\"<|sep|>4")
1196        );
1197        assert!(
1198            rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>think<|sep|>")
1199        );
1200    }
1201
1202    #[test]
1203    fn thinking_history_renders_an_empty_think_channel() {
1204        let request = Request::new(json!([
1205            {"role": "user", "content": "question"},
1206            {"role": "assistant", "content": "answer"},
1207            {"role": "user", "content": "follow-up"}
1208        ]));
1209
1210        let rendered = fmt().render(&request).unwrap();
1211
1212        assert!(rendered.contains(concat!(
1213            "<|open|>message role=\"assistant\"<|sep|>",
1214            "<|open|>think<|sep|><|close|>think<|sep|>",
1215            "<|open|>response<|sep|>answer<|close|>response<|sep|>"
1216        )));
1217    }
1218
1219    #[test]
1220    fn non_thinking_history_omits_preserved_reasoning() {
1221        let mut request = Request::new(json!([
1222            {"role": "user", "content": "question"},
1223            {
1224                "role": "assistant",
1225                "reasoning_content": "hidden reasoning",
1226                "content": "answer"
1227            },
1228            {"role": "user", "content": "follow-up"}
1229        ]));
1230        request
1231            .args
1232            .insert("thinking".to_string(), Value::Bool(false));
1233
1234        let rendered = fmt().render(&request).unwrap();
1235
1236        assert!(!rendered.contains("hidden reasoning"));
1237        assert!(!rendered.contains("<|open|>think<|sep|>"));
1238        assert!(rendered.contains(concat!(
1239            "<|open|>message role=\"assistant\"<|sep|>",
1240            "<|open|>response<|sep|>answer<|close|>response<|sep|>"
1241        )));
1242    }
1243
1244    #[test]
1245    fn tools_are_deep_sorted_before_declaration() {
1246        let mut request = Request::new(json!([{"role": "user", "content": "Weather?"}]));
1247        request
1248            .args
1249            .insert("thinking".to_string(), Value::Bool(false));
1250        request.tools = Some(json!([{
1251            "type": "function",
1252            "function": {
1253                "parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
1254                "name": "weather",
1255                "description": "Get weather"
1256            }
1257        }]));
1258        let rendered = fmt().render(&request).unwrap();
1259        assert!(rendered.contains(concat!(
1260            "[{\"function\":{\"description\":\"Get weather\",",
1261            "\"name\":\"weather\",\"parameters\":{\"properties\":",
1262            "{\"city\":{\"type\":\"string\"}},\"type\":\"object\"}},",
1263            "\"type\":\"function\"}]"
1264        )));
1265    }
1266
1267    #[test]
1268    fn assistant_history_matches_python_json_spacing_and_reasoning_fallback() {
1269        let request = Request::new(json!([{
1270            "role": "assistant",
1271            "reasoning_content": "",
1272            "reasoning": "fallback",
1273            "content": null,
1274            "tool_calls": [{
1275                "type": "function",
1276                "function": {
1277                    "name": "run",
1278                    "arguments": {
1279                        "opts": {"a": 1, "b": [true, false]}
1280                    }
1281                }
1282            }]
1283        }]));
1284        let rendered = fmt().render(&request).unwrap();
1285
1286        assert!(rendered.contains("<|open|>think<|sep|>fallback<|close|>think<|sep|>"));
1287        assert!(rendered.contains(concat!(
1288            "<|open|>argument key=\"opts\" type=\"object\"<|sep|>",
1289            "{\"a\": 1, \"b\": [true, false]}",
1290            "<|close|>argument<|sep|>"
1291        )));
1292    }
1293}