[Model] Add Kimi K3 support: Rust frontend [1/2] (#50104)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jul 29, 2026 at 05:31 UTC
2899dca8432d40632987b0ec24253a8fe6df2710
35 files changed
+3003
-39
pyproject.toml
+2
-3
@@ -129,10 +129,9 @@ extend-exclude = ["tests/models/fixtures/*", "tests/prompts/*", "tests/tokenizer
129
"tests/entrypoints/speech_to_text/transcription/test_transcription_validation.py",
130
"docs/governance/process.md", "docs/assets/contributing/vllm_bench_serve_timeline.html",
131
"tests/v1/engine/test_fast_incdec_prefix_err.py", ".git/*", "csrc/cpu/sgl-kernels/*",
132
- "rust/src/chat/src/renderer/deepseek_v32/fixtures/*",
133
- "rust/src/parser/src/tool/gemma4.rs", "rust/src/parser/src/unified/gemma4.rs",
132
+ "rust/src/chat/src/renderer/deepseek_v32/fixtures/*", "rust/src/parser/**",
133
"rust/src/text/src/output/decoded.rs",
135
- "rust/src/tokenizer/src/incremental.rs", "rust/src/parser/src/reasoning/tests.rs"]
134
+ "rust/src/tokenizer/src/incremental.rs"]
135
ignore-hidden = false
136
137
[tool.typos.default]
rust/Cargo.lock
+1
-1
@@ -2220,7 +2220,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
2220
[[package]]
2221
name = "llm-multimodal"
2222
version = "1.7.1"
2223
-source = "git+https://github.com/smg-project/llm-multimodal?rev=5390032d6dc8a3e6fdc83acd320260367eb4b9b5#5390032d6dc8a3e6fdc83acd320260367eb4b9b5"
2223
+source = "git+https://github.com/smg-project/llm-multimodal?rev=15adba5e025d8636ba4a334fb379b1371f6196a1#15adba5e025d8636ba4a334fb379b1371f6196a1"
2224
dependencies = [
2225
"anyhow",
2226
"base64 0.22.1",
rust/Cargo.toml
+1
-1
@@ -59,7 +59,7 @@ indexmap = "2.13.0"
59
indicatif = "0.18.4"
60
itertools = "0.14.0"
61
libc = "0.2.177"
62
-llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "5390032d6dc8a3e6fdc83acd320260367eb4b9b5", default-features = false, features = ["native-tls"] }
62
+llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "15adba5e025d8636ba4a334fb379b1371f6196a1", default-features = false, features = ["native-tls"] }
63
mimalloc = "0.1.52"
64
minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
65
minijinja-contrib = { version = "2.0", features = ["pycompat"] }
rust/src/chat/src/backend/hf.rs
+2
-1
@@ -20,7 +20,7 @@ use crate::output::{
20
use crate::renderer::hf::{HfChatRenderer, MultimodalRenderInfo};
21
use crate::renderer::{
22
DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, HarmonyChatRenderer,
23
- InklingChatRenderer,
23
+ InklingChatRenderer, KimiK3ChatRenderer,
24
};
25
use crate::request::ChatRequest;
26
use crate::{DynChatOutputProcessor, RendererSelection};
@@ -73,6 +73,7 @@ impl HfChatBackend {
73
RendererSelection::DeepSeekV4 => Arc::new(DeepSeekV4ChatRenderer::new()),
74
RendererSelection::Harmony => Arc::new(HarmonyChatRenderer::new()?),
75
RendererSelection::Inkling => Arc::new(InklingChatRenderer::new(tokenizer.clone())?),
76
+ RendererSelection::KimiK3 => Arc::new(KimiK3ChatRenderer::new(tokenizer.clone())),
77
};
78
79
info!(
rust/src/chat/src/lib.rs
+10
-3
@@ -33,7 +33,8 @@ pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory};
33
pub use renderer::hf::ChatTemplateContentFormatOption;
34
pub use renderer::{
35
ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer,
36
- HarmonyChatRenderer, InklingChatRenderer, RenderedPrompt, RendererSelection,
36
+ HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer, RenderedPrompt,
37
+ RendererSelection,
38
};
39
pub use request::{
40
ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRequest, ChatRole, ChatTool,
@@ -353,6 +354,12 @@ mod tests {
354
.unwrap();
355
}
356
357
+ #[test]
358
+ fn validate_parser_overrides_accepts_explicit_kimi_k3() {
359
+ let selection = ParserSelection::Explicit("kimi_k3".to_string());
360
+ validate_parser_overrides(&selection, &selection).unwrap();
361
+ }
362
+
363
#[test]
364
fn validate_parser_overrides_accepts_auto_and_none() {
365
validate_parser_overrides(&ParserSelection::Auto, &ParserSelection::None).unwrap();
@@ -366,7 +373,7 @@ mod tests {
373
)
374
.unwrap_err();
375
369
- expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, inkling, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
376
+ expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
377
}
378
379
#[test]
@@ -377,6 +384,6 @@ mod tests {
384
)
385
.unwrap_err();
386
380
- expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, inkling, kimi, kimi_k2, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
387
+ expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
388
}
389
}
rust/src/chat/src/parser/reasoning/mod.rs
+2
@@ -27,6 +27,7 @@ pub mod names {
27
pub const GLM45: &str = "glm45";
28
pub const KIMI: &str = "kimi";
29
pub const KIMI_K2: &str = "kimi_k2";
30
+ pub const KIMI_K3: &str = "kimi_k3";
31
pub const MINIMAX_M2: &str = "minimax_m2";
32
pub const MINIMAX_M3: &str = "minimax_m3";
33
pub const NEMOTRON_V3: &str = "nemotron_v3";
@@ -68,6 +69,7 @@ impl ReasoningParserFactory {
69
.register_parser::<Glm45ReasoningParser>(names::GLM45)
70
.register_parser::<KimiReasoningParser>(names::KIMI)
71
.register_parser::<KimiK2ReasoningParser>(names::KIMI_K2)
72
+ .register_unified_dummy(names::KIMI_K3)
73
.register_parser::<MiniMaxM2ReasoningParser>(names::MINIMAX_M2)
74
.register_parser::<MiniMaxM3ReasoningParser>(names::MINIMAX_M3)
75
.register_parser::<NemotronV3ReasoningParser>(names::NEMOTRON_V3)
rust/src/chat/src/parser/tool/mod.rs
+2
@@ -33,6 +33,7 @@ pub mod names {
33
// also routes to `Internlm2ToolParser` despite the version-agnostic name.
34
pub const INTERNLM: &str = "internlm";
35
pub const KIMI_K2: &str = "kimi_k2";
36
+ pub const KIMI_K3: &str = "kimi_k3";
37
pub const LLAMA3_JSON: &str = "llama3_json";
38
pub const LLAMA4_JSON: &str = "llama4_json";
39
pub const MINIMAX_M2: &str = "minimax_m2";
@@ -78,6 +79,7 @@ impl ToolParserFactory {
79
.register_parser::<HyV3ToolParser>(names::HY_V3)
80
.register_parser::<Internlm2ToolParser>(names::INTERNLM)
81
.register_parser::<KimiK2ToolParser>(names::KIMI_K2)
82
+ .register_unified_dummy(names::KIMI_K3)
83
.register_parser::<Llama3JsonToolParser>(names::LLAMA3_JSON)
84
.register_parser::<Llama3JsonToolParser>(names::LLAMA4_JSON)
85
.register_parser::<MinimaxM2ToolParser>(names::MINIMAX_M2)
rust/src/chat/src/parser/unified.rs
+24
-2
@@ -5,7 +5,9 @@
5
6
use std::sync::LazyLock;
7
8
-pub use vllm_parser::unified::{Gemma4UnifiedParser, InklingUnifiedParser, UnifiedParser};
8
+pub use vllm_parser::unified::{
9
+ Gemma4UnifiedParser, InklingUnifiedParser, KimiK3UnifiedParser, UnifiedParser,
10
+};
11
use vllm_tokenizer::DynTokenizer;
12
13
use crate::parser::ParserFactory;
@@ -15,6 +17,7 @@ use crate::request::ChatTool;
17
pub mod names {
18
pub const GEMMA4: &str = "gemma4";
19
pub const INKLING: &str = "inkling";
20
+ pub const KIMI_K3: &str = "kimi_k3";
21
}
22
23
/// Constructor signature for one registered unified parser implementation.
@@ -39,11 +42,14 @@ impl UnifiedParserFactory {
42
43
factory.register_parser::<Gemma4UnifiedParser>(names::GEMMA4);
44
factory.register_parser::<InklingUnifiedParser>(names::INKLING);
45
+ factory.register_parser::<KimiK3UnifiedParser>(names::KIMI_K3);
46
47
factory
48
.register_pattern("gemma-4", names::GEMMA4)
49
.register_pattern("gemma4", names::GEMMA4)
46
- .register_pattern("inkling", names::INKLING);
50
+ .register_pattern("inkling", names::INKLING)
51
+ .register_pattern("kimi-k3", names::KIMI_K3)
52
+ .register_pattern("kimi_k3", names::KIMI_K3);
53
54
factory
55
}
@@ -121,4 +127,20 @@ mod tests {
127
);
128
factory.create(names::INKLING, &[], Arc::new(inkling_tokenizer())).unwrap();
129
}
130
+
131
+ #[test]
132
+ fn factory_registers_kimi_k3() {
133
+ let factory = UnifiedParserFactory::new();
134
+ let tokenizer = TestTokenizer::new()
135
+ .with_regular_token("<|open|>", 1001)
136
+ .with_regular_token("<|close|>", 1002)
137
+ .with_regular_token("<|sep|>", 1003);
138
+
139
+ assert!(factory.contains(names::KIMI_K3));
140
+ assert_eq!(
141
+ factory.resolve_name_for_model("moonshotai/Kimi-K3"),
142
+ Some(names::KIMI_K3)
143
+ );
144
+ factory.create(names::KIMI_K3, &[], Arc::new(tokenizer)).unwrap();
145
+ }
146
}
rust/src/chat/src/renderer/deepseek_v32/tests.rs
+1
-1
@@ -59,7 +59,7 @@ fn fixture_request(input_name: &str) -> ChatRequest {
59
60
fn deepseek_fixture_options() -> FixtureRequestOptions {
61
FixtureRequestOptions {
62
- enable_thinking: true,
62
+ enable_thinking: Some(true),
63
no_generation_prompt_when_last_assistant: true,
64
}
65
}
rust/src/chat/src/renderer/deepseek_v4/tests.rs
+1
-1
@@ -27,7 +27,7 @@ fn fixture_request(input_name: &str) -> ChatRequest {
27
28
fn deepseek_fixture_options() -> FixtureRequestOptions {
29
FixtureRequestOptions {
30
- enable_thinking: true,
30
+ enable_thinking: Some(true),
31
no_generation_prompt_when_last_assistant: true,
32
}
33
}
rust/src/chat/src/renderer/harmony/tests.rs
+1
-1
@@ -22,7 +22,7 @@ fn fixture_request(input_name: &str) -> ChatRequest {
22
fixture_chat_request(
23
&fixture_path(input_name),
24
FixtureRequestOptions {
25
- enable_thinking: false,
25
+ enable_thinking: Some(false),
26
no_generation_prompt_when_last_assistant: false,
27
},
28
)
rust/src/chat/src/renderer/inkling/tests.rs
+1
-1
@@ -105,7 +105,7 @@ fn fixture_request(name: &str) -> ChatRequest {
105
106
fn inkling_fixture_options() -> FixtureRequestOptions {
107
FixtureRequestOptions {
108
- enable_thinking: false,
108
+ enable_thinking: Some(false),
109
no_generation_prompt_when_last_assistant: false,
110
}
111
}
rust/src/chat/src/renderer/kimi_k3/encoding.rs
new
+608
@@ -0,0 +1,608 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Kimi K3 XTML prompt renderer.
5
+//!
6
+//! Port of Moonshot remote-code `encoding_k3.py::build_chat_segments()`.
7
+
8
+use std::collections::HashMap;
9
+
10
+use serde_json::{Map, Value, json};
11
+use vllm_tokenizer::Tokenizer;
12
+
13
+use crate::error::{Error, Result};
14
+use crate::request::{
15
+ ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice,
16
+};
17
+use crate::{AssistantContentBlock, AssistantToolCall};
18
+
19
+pub(super) const OPEN: &str = "<|open|>";
20
+pub(super) const CLOSE: &str = "<|close|>";
21
+pub(super) const SEP: &str = "<|sep|>";
22
+pub(super) const END_OF_MSG: &str = "<|end_of_msg|>";
23
+pub(super) const IMAGE_PLACEHOLDER: &str = "<|media_pad|>";
24
+
25
+const DEFAULT_THINKING_EFFORT: &str = "max";
26
+const VALID_THINKING_EFFORTS: &[&str] = &["low", "high", "max"];
27
+
28
+/// K3 prompt encoder preserving Python's per-segment tokenization boundaries.
29
+pub(super) struct K3TokenWriter<'a> {
30
+ tokenizer: &'a dyn Tokenizer,
31
+ token_ids: Vec<u32>,
32
+}
33
+
34
+impl<'a> K3TokenWriter<'a> {
35
+ pub(super) fn new(tokenizer: &'a dyn Tokenizer) -> Self {
36
+ Self {
37
+ tokenizer,
38
+ token_ids: Vec::new(),
39
+ }
40
+ }
41
+
42
+ /// Encode one trusted segment with normal added-token recognition.
43
+ pub(super) fn control(&mut self, text: &str) -> Result<()> {
44
+ if !text.is_empty() {
45
+ self.token_ids.extend(self.tokenizer.encode(text, false)?);
46
+ }
47
+ Ok(())
48
+ }
49
+
50
+ /// Encode one literal segment while bypassing every added-token matcher.
51
+ pub(super) fn ordinary(&mut self, text: &str) -> Result<()> {
52
+ if !text.is_empty() {
53
+ self.token_ids.extend(self.tokenizer.encode_ordinary(text)?);
54
+ }
55
+ Ok(())
56
+ }
57
+
58
+ pub(super) fn finish(self) -> Vec<u32> {
59
+ self.token_ids
60
+ }
61
+}
62
+
63
+/// Render and tokenize one chat request using K3's segment-aware contract.
64
+pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -> Result<Vec<u32>> {
65
+ let thinking = thinking_enabled(request)?;
66
+ let thinking_effort = thinking.then(|| thinking_effort(request)).transpose()?;
67
+ let tools = request_tools(request);
68
+ let mut out = K3TokenWriter::new(tokenizer);
69
+
70
+ if !tools.is_empty() {
71
+ write_tool_declare(&mut out, tools, false)?;
72
+ }
73
+
74
+ if let Some(effort) = thinking_effort {
75
+ // Preserve the checkpoint's literal guidance text: it still names
76
+ // `medium`, although the validator above no longer accepts it.
77
+ write_internal_system(
78
+ &mut out,
79
+ "thinking-effort",
80
+ &format!(
81
+ "`thinking_effort` guides on how much to think in your \
82
+ thinking channel (not including the response channel), \
83
+ supported values include `low`, `medium`, `high`, and `max`.\n\
84
+ Now the system is invoked with `thinking_effort={effort}`."
85
+ ),
86
+ )?;
87
+ }
88
+
89
+ // Track prior assistant tool-call ids for tool-result reordering / naming.
90
+ let mut tool_call_id_index: HashMap<String, (usize, String)> = HashMap::new();
91
+ let mut pending_tool_run: Vec<(usize, ChatMessage)> = Vec::new();
92
+
93
+ let flush_tool_run = |out: &mut K3TokenWriter<'_>,
94
+ run: &mut Vec<(usize, ChatMessage)>,
95
+ id_index: &HashMap<String, (usize, String)>|
96
+ -> Result<()> {
97
+ if run.is_empty() {
98
+ return Ok(());
99
+ }
100
+
101
+ let mut resolved = Vec::with_capacity(run.len());
102
+ let mut unresolved = false;
103
+ for (offset, message) in run.drain(..) {
104
+ let ChatMessage::ToolResponse {
105
+ content,
106
+ tool_call_id,
107
+ } = message
108
+ else {
109
+ unreachable!("pending tool run only holds tool responses");
110
+ };
111
+ match id_index.get(&tool_call_id) {
112
+ Some(&(position, ref name)) => {
113
+ resolved.push((position, offset, content, Some(name.clone())));
114
+ }
115
+ None => {
116
+ unresolved = true;
117
+ resolved.push((usize::MAX, offset, content, None));
118
+ }
119
+ }
120
+ }
121
+
122
+ if unresolved {
123
+ // Preserve original order when the run cannot be fully matched.
124
+ resolved.sort_by_key(|item| item.1);
125
+ } else {
126
+ resolved.sort_by_key(|item| (item.0, item.1));
127
+ }
128
+
129
+ for (xtml_index, (_, _, content, name)) in resolved.into_iter().enumerate() {
130
+ let tool_name = name.as_deref().ok_or_else(|| {
131
+ Error::ChatTemplate(
132
+ "Kimi K3 tool messages need a resolvable tool name: \
133
+ carry a matching tool_call_id against a preceding \
134
+ assistant tool_call"
135
+ .to_string(),
136
+ )
137
+ })?;
138
+ write_tool_message(out, tool_name, xtml_index + 1, &content)?;
139
+ }
140
+ Ok(())
141
+ };
142
+
143
+ for (message_index, message) in request.messages.iter().enumerate() {
144
+ match message {
145
+ ChatMessage::ToolResponse { .. } => {
146
+ pending_tool_run.push((message_index, message.clone()));
147
+ continue;
148
+ }
149
+ _ => {
150
+ flush_tool_run(&mut out, &mut pending_tool_run, &tool_call_id_index)?;
151
+ }
152
+ }
153
+
154
+ match message {
155
+ // Python: role=system with a `tools` field renders a dynamic
156
+ // tool-declare (`## New Tools Available`). Map that to Developer
157
+ // messages that carry tools (OpenAI "developer" / system-tools).
158
+ ChatMessage::Developer {
159
+ content,
160
+ tools: Some(local_tools),
161
+ } if !local_tools.is_empty() => {
162
+ write_tool_declare(&mut out, local_tools, true)?;
163
+ if !content_is_empty(content) {
164
+ write_role_message(&mut out, "system", None, content)?;
165
+ }
166
+ }
167
+ ChatMessage::System { content } | ChatMessage::Developer { content, .. } => {
168
+ write_role_message(&mut out, "system", None, content)?;
169
+ }
170
+ ChatMessage::User { content } => {
171
+ write_role_message(&mut out, "user", None, content)?;
172
+ }
173
+ ChatMessage::Assistant { content } => {
174
+ tool_call_id_index.clear();
175
+ let mut call_position = 0usize;
176
+ for block in content {
177
+ if let AssistantContentBlock::ToolCall(call) = block {
178
+ call_position += 1;
179
+ if !call.id.is_empty() {
180
+ tool_call_id_index
181
+ .entry(call.id.clone())
182
+ .or_insert((call_position, call.name.clone()));
183
+ }
184
+ }
185
+ }
186
+
187
+ write_assistant_message(&mut out, content, thinking)?;
188
+ }
189
+ ChatMessage::ToolResponse { .. } => unreachable!("handled above"),
190
+ }
191
+ }
192
+ flush_tool_run(&mut out, &mut pending_tool_run, &tool_call_id_index)?;
193
+
194
+ match &request.tool_choice {
195
+ ChatToolChoice::Required => {
196
+ write_internal_system(
197
+ &mut out,
198
+ "tool-choice",
199
+ "The system is invoked with `tool_choice=required`.\n\
200
+ You MUST call tools in the next message.",
201
+ )?;
202
+ }
203
+ // Emit only when tools are present: Rust defaults tool_choice to None
204
+ // for tool-free requests, which must not inject a tool-choice message.
205
+ ChatToolChoice::None if !request.tools.is_empty() => {
206
+ write_internal_system(
207
+ &mut out,
208
+ "tool-choice",
209
+ "The system is invoked with `tool_choice=none`.\n\
210
+ You MUST NOT call any tools in the next message.",
211
+ )?;
212
+ }
213
+ ChatToolChoice::None | ChatToolChoice::Auto | ChatToolChoice::Function { .. } => {}
214
+ }
215
+
216
+ write_response_format(&mut out, request)?;
217
+
218
+ if request.chat_options.add_generation_prompt() {
219
+ write_open_tag(&mut out, "message", &[("role", "assistant")])?;
220
+ write_open_tag(&mut out, if thinking { "think" } else { "response" }, &[])?;
221
+ }
222
+
223
+ Ok(out.finish())
224
+}
225
+
226
+fn request_tools(request: &ChatRequest) -> &[ChatTool] {
227
+ // Declare tools whenever the request carries them. K3 tool-declare is
228
+ // independent of tool_choice; tool_choice only injects control messages.
229
+ request.tools.as_slice()
230
+}
231
+
232
+fn thinking_enabled(request: &ChatRequest) -> Result<bool> {
233
+ if let Some(thinking) = request.parse_template_bool("thinking")? {
234
+ return Ok(thinking);
235
+ }
236
+ if let Some(enable_thinking) = request.parse_template_bool("enable_thinking")? {
237
+ return Ok(enable_thinking);
238
+ }
239
+ Ok(request
240
+ .chat_options
241
+ .reasoning_effort
242
+ .map(|effort| effort != crate::request::ReasoningEffort::None)
243
+ .unwrap_or(true))
244
+}
245
+
246
+fn thinking_effort(request: &ChatRequest) -> Result<String> {
247
+ let effort = if let Some(value) = request.chat_options.template_kwargs.get("thinking_effort") {
248
+ value.as_str().ok_or_else(|| {
249
+ Error::ChatTemplate(format!(
250
+ "template kwarg `thinking_effort` must be a string, got {value}"
251
+ ))
252
+ })?
253
+ } else if let Some(effort) = request.chat_options.reasoning_effort {
254
+ effort.as_str()
255
+ } else if let Some(value) = request.chat_options.template_kwargs.get("reasoning_effort") {
256
+ value.as_str().ok_or_else(|| {
257
+ Error::ChatTemplate(format!(
258
+ "template kwarg `reasoning_effort` must be a string, got {value}"
259
+ ))
260
+ })?
261
+ } else {
262
+ DEFAULT_THINKING_EFFORT
263
+ };
264
+
265
+ if !VALID_THINKING_EFFORTS.contains(&effort) {
266
+ return Err(Error::ChatTemplate(format!(
267
+ "unsupported thinking_effort={effort:?}; supported values are `low`, `high`, and `max`"
268
+ )));
269
+ }
270
+ Ok(effort.to_string())
271
+}
272
+
273
+fn content_is_empty(content: &ChatContent) -> bool {
274
+ match content {
275
+ ChatContent::Text(text) => text.is_empty(),
276
+ ChatContent::Parts(parts) => parts.iter().all(|part| match part {
277
+ ChatContentPart::Text { text } => text.is_empty(),
278
+ ChatContentPart::ImageUrl { .. }
279
+ | ChatContentPart::VideoUrl { .. }
280
+ | ChatContentPart::InputAudio { .. }
281
+ | ChatContentPart::AudioUrl { .. } => false,
282
+ }),
283
+ }
284
+}
285
+
286
+fn write_tool_declare(
287
+ out: &mut K3TokenWriter<'_>,
288
+ tools: &[ChatTool],
289
+ dynamic: bool,
290
+) -> Result<()> {
291
+ let mut specs = Vec::with_capacity(tools.len());
292
+ for tool in tools {
293
+ let mut function = Map::new();
294
+ function.insert(
295
+ "description".to_string(),
296
+ Value::String(tool.description.clone().unwrap_or_default()),
297
+ );
298
+ function.insert("name".to_string(), Value::String(tool.name.clone()));
299
+ function.insert("parameters".to_string(), sort_json(&tool.parameters));
300
+ specs.push(json!({
301
+ "function": Value::Object(function),
302
+ "type": "function",
303
+ }));
304
+ }
305
+ let payload = compact_json(&sort_json(&Value::Array(specs)))?;
306
+
307
+ let body = if dynamic {
308
+ format!(
309
+ "## New Tools Available\n\
310
+ The system dynamically extends the toolset via lazy-loading.\n\
311
+ You have access to all existing and extended tools.\n\
312
+ Here are the specs for the extended tools.\n\n\
313
+ ```json\n\
314
+ {payload}\n\
315
+ ```"
316
+ )
317
+ } else {
318
+ format!(
319
+ "# Tools\n\
320
+ Here are the available tools, described in JSONSchema.\n\n\
321
+ ```json\n\
322
+ {payload}\n\
323
+ ```"
324
+ )
325
+ };
326
+
327
+ write_internal_system(out, "tool-declare", &body)
328
+}
329
+
330
+fn write_internal_system(
331
+ out: &mut K3TokenWriter<'_>,
332
+ message_type: &str,
333
+ body: &str,
334
+) -> Result<()> {
335
+ write_open_tag(
336
+ out,
337
+ "message",
338
+ &[("role", "system"), ("type", message_type)],
339
+ )?;
340
+ out.ordinary(body.trim())?;
341
+ write_close_tag(out, "message")?;
342
+ out.control(END_OF_MSG)
343
+}
344
+
345
+fn write_role_message(
346
+ out: &mut K3TokenWriter<'_>,
347
+ role: &str,
348
+ name: Option<&str>,
349
+ content: &ChatContent,
350
+) -> Result<()> {
351
+ let mut attrs = vec![("role", role.to_string())];
352
+ if let Some(name) = name.filter(|name| !name.is_empty()) {
353
+ attrs.push(("name", name.to_string()));
354
+ }
355
+ let attr_refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
356
+ write_open_tag(out, "message", &attr_refs)?;
357
+ write_content(out, content)?;
358
+ write_close_tag(out, "message")?;
359
+ out.control(END_OF_MSG)
360
+}
361
+
362
+fn write_tool_message(
363
+ out: &mut K3TokenWriter<'_>,
364
+ tool_name: &str,
365
+ index: usize,
366
+ content: &ChatContent,
367
+) -> Result<()> {
368
+ let index_str = index.to_string();
369
+ write_open_tag(
370
+ out,
371
+ "message",
372
+ &[("role", "tool"), ("tool", tool_name), ("index", &index_str)],
373
+ )?;
374
+ write_content(out, content)?;
375
+ write_close_tag(out, "message")?;
376
+ out.control(END_OF_MSG)
377
+}
378
+
379
+fn write_assistant_message(
380
+ out: &mut K3TokenWriter<'_>,
381
+ content: &[AssistantContentBlock],
382
+ thinking: bool,
383
+) -> Result<()> {
384
+ write_open_tag(out, "message", &[("role", "assistant")])?;
385
+
386
+ let mut reasoning = String::new();
387
+ let mut response = String::new();
388
+ let mut tool_calls = Vec::new();
389
+ for block in content {
390
+ match block {
391
+ AssistantContentBlock::Reasoning { text } => reasoning.push_str(text),
392
+ AssistantContentBlock::Text { text } => response.push_str(text),
393
+ AssistantContentBlock::ToolCall(call) => tool_calls.push(call),
394
+ }
395
+ }
396
+
397
+ // The think channel is structural: in thinking mode every assistant
398
+ // message carries open/close tags even when there is no reasoning content.
399
+ // In non-thinking mode the channel is dropped entirely.
400
+ if thinking {
401
+ write_open_tag(out, "think", &[])?;
402
+ if !reasoning.trim().is_empty() {
403
+ out.ordinary(&reasoning)?;
404
+ }
405
+ write_close_tag(out, "think")?;
406
+ }
407
+
408
+ write_open_tag(out, "response", &[])?;
409
+ out.ordinary(&response)?;
410
+ write_close_tag(out, "response")?;
411
+
412
+ if !tool_calls.is_empty() {
413
+ write_open_tag(out, "tools", &[])?;
414
+ for (index, tool_call) in tool_calls.into_iter().enumerate() {
415
+ write_assistant_tool_call(out, tool_call, index + 1)?;
416
+ }
417
+ write_close_tag(out, "tools")?;
418
+ }
419
+
420
+ write_close_tag(out, "message")?;
421
+ out.control(END_OF_MSG)
422
+}
423
+
424
+fn write_assistant_tool_call(
425
+ out: &mut K3TokenWriter<'_>,
426
+ tool_call: &AssistantToolCall,
427
+ index: usize,
428
+) -> Result<()> {
429
+ let index_str = index.to_string();
430
+ write_open_tag(
431
+ out,
432
+ "call",
433
+ &[
434
+ ("tool", tool_call.name.as_str()),
435
+ ("index", index_str.as_str()),
436
+ ],
437
+ )?;
438
+
439
+ let (args, json_block) = normalize_tool_arguments(&tool_call.arguments)?;
440
+ if let Some(raw) = json_block {
441
+ write_open_tag(out, "json", &[("type", "object")])?;
442
+ out.ordinary(&raw)?;
443
+ write_close_tag(out, "json")?;
444
+ } else {
445
+ for (key, value) in args {
446
+ let typ = xtml_type(&value);
447
+ write_open_tag(out, "argument", &[("key", key.as_str()), ("type", typ)])?;
448
+ out.ordinary(&xtml_value(&value))?;
449
+ write_close_tag(out, "argument")?;
450
+ }
451
+ }
452
+
453
+ write_close_tag(out, "call")
454
+}
455
+
456
+fn write_content(out: &mut K3TokenWriter<'_>, content: &ChatContent) -> Result<()> {
457
+ match content {
458
+ ChatContent::Text(text) => write_text_with_images(out, text),
459
+ ChatContent::Parts(parts) => {
460
+ for part in parts {
461
+ match part {
462
+ ChatContentPart::Text { text } => write_text_with_images(out, text)?,
463
+ ChatContentPart::ImageUrl { .. } => out.control(IMAGE_PLACEHOLDER)?,
464
+ ChatContentPart::VideoUrl { .. } => {
465
+ return Err(Error::UnsupportedMultimodalContent("video_url"));
466
+ }
467
+ ChatContentPart::InputAudio { .. } => {
468
+ return Err(Error::UnsupportedMultimodalContent("input_audio"));
469
+ }
470
+ ChatContentPart::AudioUrl { .. } => {
471
+ return Err(Error::UnsupportedMultimodalContent("audio_url"));
472
+ }
473
+ }
474
+ }
475
+ Ok(())
476
+ }
477
+ }
478
+}
479
+
480
+fn write_text_with_images(out: &mut K3TokenWriter<'_>, text: &str) -> Result<()> {
481
+ // Placeholder expansion is left as the literal K3 image token; multimodal
482
+ // preprocessing can replace it once image prompts are known.
483
+ out.ordinary(text)
484
+}
485
+
486
+fn write_response_format(out: &mut K3TokenWriter<'_>, request: &ChatRequest) -> Result<()> {
487
+ let Some(rf) = request.chat_options.response_format.as_ref() else {
488
+ return Ok(());
489
+ };
490
+
491
+ let rf_type = rf.get("type").and_then(Value::as_str).or_else(|| rf.as_str()).unwrap_or("");
492
+
493
+ match rf_type {
494
+ "json_object" => {
495
+ write_internal_system(
496
+ out,
497
+ "response-format",
498
+ "The system is invoked with `response_format=json_object`.\n\
499
+ Your response must be raw JSON data without markdown code \
500
+ blocks (```json) or any additional formatting.",
501
+ )?;
502
+ }
503
+ "json_schema" => {
504
+ let schema = extract_response_schema(rf);
505
+ let schema_json = compact_json(&sort_json(&schema.unwrap_or(Value::Null)))?;
506
+ write_internal_system(
507
+ out,
508
+ "response-format",
509
+ &format!(
510
+ "The system is invoked with `response_format=json_schema`.\n\
511
+ Your response must be raw JSON data without markdown code \
512
+ blocks (```json) or any additional formatting.\n\
513
+ The JSON data must match the following schema:\n\
514
+ ```json\n\
515
+ {schema_json}\n\
516
+ ```"
517
+ ),
518
+ )?;
519
+ }
520
+ _ => {}
521
+ }
522
+ Ok(())
523
+}
524
+
525
+fn extract_response_schema(response_format: &Value) -> Option<Value> {
526
+ let json_schema = response_format.get("json_schema")?;
527
+ if let Some(schema) = json_schema.get("schema") {
528
+ return Some(schema.clone());
529
+ }
530
+ if let Some(schema) = json_schema.get("json_schema") {
531
+ return Some(schema.clone());
532
+ }
533
+ Some(json_schema.clone())
534
+}
535
+
536
+fn normalize_tool_arguments(arguments: &str) -> Result<(Map<String, Value>, Option<String>)> {
537
+ let trimmed = arguments.trim();
538
+ if trimmed.is_empty() {
539
+ return Ok((Map::new(), None));
540
+ }
541
+ match serde_json::from_str::<Value>(trimmed) {
542
+ Ok(Value::Object(map)) => Ok((map, None)),
543
+ Ok(_) => Err(Error::ChatTemplate(
544
+ "Kimi K3 tool call arguments must be a JSON object".to_string(),
545
+ )),
546
+ Err(_) => Ok((Map::new(), Some(arguments.to_string()))),
547
+ }
548
+}
549
+
550
+fn xtml_type(value: &Value) -> &'static str {
551
+ match value {
552
+ Value::Bool(_) => "boolean",
553
+ Value::Null => "null",
554
+ Value::Number(_) => "number",
555
+ Value::String(_) => "string",
556
+ Value::Object(_) => "object",
557
+ Value::Array(_) => "array",
558
+ }
559
+}
560
+
561
+fn xtml_value(value: &Value) -> String {
562
+ match value {
563
+ Value::String(text) => text.clone(),
564
+ other => compact_json(other).unwrap_or_else(|_| other.to_string()),
565
+ }
566
+}
567
+
568
+fn write_open_tag(out: &mut K3TokenWriter<'_>, tag: &str, attrs: &[(&str, &str)]) -> Result<()> {
569
+ out.control(OPEN)?;
570
+ out.ordinary(tag)?;
571
+ for (key, value) in attrs {
572
+ out.ordinary(&format!(" {key}"))?;
573
+ out.ordinary("=\"")?;
574
+ out.ordinary(&escape_attr_value(value))?;
575
+ out.ordinary("\"")?;
576
+ }
577
+ out.control(SEP)
578
+}
579
+
580
+fn write_close_tag(out: &mut K3TokenWriter<'_>, tag: &str) -> Result<()> {
581
+ out.control(CLOSE)?;
582
+ out.ordinary(tag)?;
583
+ out.control(SEP)
584
+}
585
+
586
+fn escape_attr_value(value: &str) -> String {
587
+ value.replace('&', "&").replace('"', """)
588
+}
589
+
590
+fn compact_json(value: &Value) -> Result<String> {
591
+ serde_json::to_string(value).map_err(|error| Error::ChatTemplate(error.to_string()))
592
+}
593
+
594
+fn sort_json(value: &Value) -> Value {
595
+ match value {
596
+ Value::Array(items) => Value::Array(items.iter().map(sort_json).collect()),
597
+ Value::Object(map) => {
598
+ let mut sorted = Map::new();
599
+ let mut keys = map.keys().collect::<Vec<_>>();
600
+ keys.sort();
601
+ for key in keys {
602
+ sorted.insert(key.clone(), sort_json(&map[key]));
603
+ }
604
+ Value::Object(sorted)
605
+ }
606
+ _ => value.clone(),
607
+ }
608
+}
rust/src/chat/src/renderer/kimi_k3/fixtures/controls_thinking_off_input.json
new
+15
@@ -0,0 +1,15 @@
1
+{
2
+ "messages": [
3
+ {
4
+ "role": "user",
5
+ "content": "json pls"
6
+ }
7
+ ],
8
+ "add_generation_prompt": true,
9
+ "response_format": {
10
+ "type": "json_object"
11
+ },
12
+ "template_kwargs": {
13
+ "thinking": false
14
+ }
15
+}
rust/src/chat/src/renderer/kimi_k3/fixtures/controls_thinking_off_output.txt
new
+2
@@ -0,0 +1,2 @@
1
+<|open|>message role="user"<|sep|>json pls<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="response-format"<|sep|>The system is invoked with `response_format=json_object`.
2
+Your response must be raw JSON data without markdown code blocks (```json) or any additional formatting.<|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>response<|sep|>
\ No newline at end of file
rust/src/chat/src/renderer/kimi_k3/fixtures/dynamic_system_tool_declare_input.json
new
+62
@@ -0,0 +1,62 @@
1
+{
2
+ "tools": [
3
+ {
4
+ "type": "function",
5
+ "function": {
6
+ "name": "get_weather",
7
+ "description": "weather",
8
+ "parameters": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ },
14
+ "days": {
15
+ "type": "number"
16
+ }
17
+ },
18
+ "required": [
19
+ "city"
20
+ ]
21
+ }
22
+ }
23
+ }
24
+ ],
25
+ "messages": [
26
+ {
27
+ "role": "user",
28
+ "content": "hi"
29
+ },
30
+ {
31
+ "role": "developer",
32
+ "tools": [
33
+ {
34
+ "type": "function",
35
+ "function": {
36
+ "name": "calc",
37
+ "description": "calculator",
38
+ "parameters": {
39
+ "type": "object",
40
+ "properties": {
41
+ "x": {
42
+ "type": "number"
43
+ }
44
+ },
45
+ "required": [
46
+ "x"
47
+ ]
48
+ }
49
+ }
50
+ }
51
+ ]
52
+ },
53
+ {
54
+ "role": "user",
55
+ "content": "use calc"
56
+ }
57
+ ],
58
+ "add_generation_prompt": true,
59
+ "template_kwargs": {
60
+ "thinking": true
61
+ }
62
+}
rust/src/chat/src/renderer/kimi_k3/fixtures/dynamic_system_tool_declare_output.txt
new
+14
@@ -0,0 +1,14 @@
1
+<|open|>message role="system" type="tool-declare"<|sep|># Tools
2
+Here are the available tools, described in JSONSchema.
3
+
4
+```json
5
+[{"function":{"description":"weather","name":"get_weather","parameters":{"properties":{"city":{"type":"string"},"days":{"type":"number"}},"required":["city"],"type":"object"}},"type":"function"}]
6
+```<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="thinking-effort"<|sep|>`thinking_effort` guides on how much to think in your thinking channel (not including the response channel), supported values include `low`, `medium`, `high`, and `max`.
7
+Now the system is invoked with `thinking_effort=max`.<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>hi<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="tool-declare"<|sep|>## New Tools Available
8
+The system dynamically extends the toolset via lazy-loading.
9
+You have access to all existing and extended tools.
10
+Here are the specs for the extended tools.
11
+
12
+```json
13
+[{"function":{"description":"calculator","name":"calc","parameters":{"properties":{"x":{"type":"number"}},"required":["x"],"type":"object"}},"type":"function"}]
14
+```<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>use calc<|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>think<|sep|>
\ No newline at end of file
rust/src/chat/src/renderer/kimi_k3/fixtures/history_preserve_and_image_input.json
new
+31
@@ -0,0 +1,31 @@
1
+{
2
+ "messages": [
3
+ {
4
+ "role": "user",
5
+ "content": [
6
+ {
7
+ "type": "text",
8
+ "text": "see this"
9
+ },
10
+ {
11
+ "type": "image_url",
12
+ "image_url": "https://example.com/a.png"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "role": "assistant",
18
+ "content": "old answer",
19
+ "reasoning_content": "old reasoning"
20
+ },
21
+ {
22
+ "role": "user",
23
+ "content": "continue"
24
+ }
25
+ ],
26
+ "add_generation_prompt": true,
27
+ "template_kwargs": {
28
+ "thinking": true,
29
+ "thinking_effort": "high"
30
+ }
31
+}
rust/src/chat/src/renderer/kimi_k3/fixtures/history_preserve_and_image_output.txt
new
+2
@@ -0,0 +1,2 @@
1
+<|open|>message role="system" type="thinking-effort"<|sep|>`thinking_effort` guides on how much to think in your thinking channel (not including the response channel), supported values include `low`, `medium`, `high`, and `max`.
2
+Now the system is invoked with `thinking_effort=high`.<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>see this<|media_pad|><|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>think<|sep|>old reasoning<|close|>think<|sep|><|open|>response<|sep|>old answer<|close|>response<|sep|><|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>continue<|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>think<|sep|>
\ No newline at end of file
rust/src/chat/src/renderer/kimi_k3/fixtures/tools_history_and_required_input.json
new
+87
@@ -0,0 +1,87 @@
1
+{
2
+ "tools": [
3
+ {
4
+ "type": "function",
5
+ "function": {
6
+ "name": "get_weather",
7
+ "description": "weather",
8
+ "parameters": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ },
14
+ "days": {
15
+ "type": "number"
16
+ }
17
+ },
18
+ "required": [
19
+ "city"
20
+ ]
21
+ }
22
+ }
23
+ },
24
+ {
25
+ "type": "function",
26
+ "function": {
27
+ "name": "calc",
28
+ "description": "calculator",
29
+ "parameters": {
30
+ "type": "object",
31
+ "properties": {
32
+ "x": {
33
+ "type": "number"
34
+ }
35
+ },
36
+ "required": [
37
+ "x"
38
+ ]
39
+ }
40
+ }
41
+ }
42
+ ],
43
+ "messages": [
44
+ {
45
+ "role": "user",
46
+ "content": "Hangzhou weather and calc"
47
+ },
48
+ {
49
+ "role": "assistant",
50
+ "content": "I'll check.",
51
+ "reasoning_content": "Need tools.",
52
+ "tool_calls": [
53
+ {
54
+ "id": "get_weather:0",
55
+ "type": "function",
56
+ "function": {
57
+ "name": "get_weather",
58
+ "arguments": "{\"city\":\"Hangzhou\",\"days\":1}"
59
+ }
60
+ },
61
+ {
62
+ "id": "calc:1",
63
+ "type": "function",
64
+ "function": {
65
+ "name": "calc",
66
+ "arguments": "{\"x\":2}"
67
+ }
68
+ }
69
+ ]
70
+ },
71
+ {
72
+ "role": "tool",
73
+ "tool_call_id": "calc:1",
74
+ "content": "2"
75
+ },
76
+ {
77
+ "role": "tool",
78
+ "tool_call_id": "get_weather:0",
79
+ "content": "{\"city\":\"Hangzhou\",\"condition\":\"rain\"}"
80
+ }
81
+ ],
82
+ "add_generation_prompt": true,
83
+ "tool_choice": "required",
84
+ "template_kwargs": {
85
+ "thinking": true
86
+ }
87
+}
rust/src/chat/src/renderer/kimi_k3/fixtures/tools_history_and_required_output.txt
new
+8
@@ -0,0 +1,8 @@
1
+<|open|>message role="system" type="tool-declare"<|sep|># Tools
2
+Here are the available tools, described in JSONSchema.
3
+
4
+```json
5
+[{"function":{"description":"weather","name":"get_weather","parameters":{"properties":{"city":{"type":"string"},"days":{"type":"number"}},"required":["city"],"type":"object"}},"type":"function"},{"function":{"description":"calculator","name":"calc","parameters":{"properties":{"x":{"type":"number"}},"required":["x"],"type":"object"}},"type":"function"}]
6
+```<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="thinking-effort"<|sep|>`thinking_effort` guides on how much to think in your thinking channel (not including the response channel), supported values include `low`, `medium`, `high`, and `max`.
7
+Now the system is invoked with `thinking_effort=max`.<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>Hangzhou weather and calc<|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>think<|sep|>Need tools.<|close|>think<|sep|><|open|>response<|sep|>I'll check.<|close|>response<|sep|><|open|>tools<|sep|><|open|>call tool="get_weather" index="1"<|sep|><|open|>argument key="city" type="string"<|sep|>Hangzhou<|close|>argument<|sep|><|open|>argument key="days" type="number"<|sep|>1<|close|>argument<|sep|><|close|>call<|sep|><|open|>call tool="calc" index="2"<|sep|><|open|>argument key="x" type="number"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|><|close|>message<|sep|><|end_of_msg|><|open|>message role="tool" tool="get_weather" index="1"<|sep|>{"city":"Hangzhou","condition":"rain"}<|close|>message<|sep|><|end_of_msg|><|open|>message role="tool" tool="calc" index="2"<|sep|>2<|close|>message<|sep|><|end_of_msg|><|open|>message role="system" type="tool-choice"<|sep|>The system is invoked with `tool_choice=required`.
8
+You MUST call tools in the next message.<|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>think<|sep|>
\ No newline at end of file
rust/src/chat/src/renderer/kimi_k3/mod.rs
new
+39
@@ -0,0 +1,39 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Native Kimi K3 XTML chat renderer.
5
+
6
+mod encoding;
7
+#[cfg(test)]
8
+mod tests;
9
+
10
+use vllm_text::Prompt;
11
+use vllm_text::tokenizer::DynTokenizer;
12
+
13
+use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
14
+use crate::Result;
15
+use crate::request::ChatRequest;
16
+
17
+/// Dedicated Kimi K3 XTML renderer.
18
+#[derive(Clone)]
19
+pub struct KimiK3ChatRenderer {
20
+ tokenizer: DynTokenizer,
21
+}
22
+
23
+impl KimiK3ChatRenderer {
24
+ /// Create a Kimi K3 renderer.
25
+ pub fn new(tokenizer: DynTokenizer) -> Self {
26
+ Self { tokenizer }
27
+ }
28
+}
29
+
30
+impl ChatRenderer for KimiK3ChatRenderer {
31
+ fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
32
+ request.validate()?;
33
+
34
+ Ok(RenderedPrompt {
35
+ prompt: Prompt::TokenIds(encoding::render_request(request, self.tokenizer.as_ref())?),
36
+ effective_template_kwargs: request_template_kwargs(request),
37
+ })
38
+ }
39
+}
rust/src/chat/src/renderer/kimi_k3/tests.rs
new
+267
@@ -0,0 +1,267 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Golden fixtures generated from HF remote-code `encoding_k3.py`.
5
+
6
+use std::path::PathBuf;
7
+use std::sync::Arc;
8
+
9
+use expect_test::{expect, expect_file};
10
+use serde_json::json;
11
+use vllm_text::Prompt;
12
+use vllm_text::tokenizer::DynTokenizer;
13
+use vllm_tokenizer::Tokenizer;
14
+use vllm_tokenizer::test_utils::TestTokenizer;
15
+
16
+use super::KimiK3ChatRenderer;
17
+use crate::AssistantContentBlock;
18
+use crate::ChatRenderer;
19
+use crate::renderer::kimi_k3::encoding::{CLOSE, END_OF_MSG, IMAGE_PLACEHOLDER, OPEN, SEP};
20
+use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
21
+use crate::request::{ChatContentPart, ChatMessage, GenerationPromptMode, ReasoningEffort};
22
+
23
+const OPEN_ID: u32 = 256;
24
+const CLOSE_ID: u32 = 257;
25
+const SEP_ID: u32 = 258;
26
+const END_OF_MSG_ID: u32 = 259;
27
+const MEDIA_ID: u32 = 260;
28
+
29
+fn test_tokenizer() -> TestTokenizer {
30
+ TestTokenizer::new()
31
+ .with_special_token(OPEN, OPEN_ID)
32
+ .with_special_token(CLOSE, CLOSE_ID)
33
+ .with_special_token(SEP, SEP_ID)
34
+ .with_special_token(END_OF_MSG, END_OF_MSG_ID)
35
+ .with_special_token(IMAGE_PLACEHOLDER, MEDIA_ID)
36
+}
37
+
38
+fn render_token_ids(request: &crate::request::ChatRequest, tokenizer: DynTokenizer) -> Vec<u32> {
39
+ let prompt = KimiK3ChatRenderer::new(tokenizer).render(request).unwrap().prompt;
40
+ let Prompt::TokenIds(token_ids) = prompt else {
41
+ panic!("kimi k3 renderer should return token IDs")
42
+ };
43
+ token_ids
44
+}
45
+
46
+fn render_request(request: &crate::request::ChatRequest) -> String {
47
+ let tokenizer: DynTokenizer = Arc::new(test_tokenizer());
48
+ let token_ids = render_token_ids(request, tokenizer.clone());
49
+ tokenizer.decode(&token_ids, false).unwrap()
50
+}
51
+
52
+fn fixture_path(name: &str) -> PathBuf {
53
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
54
+ .join("src/renderer/kimi_k3/fixtures")
55
+ .join(name)
56
+}
57
+
58
+fn kimi_k3_fixture_options() -> FixtureRequestOptions {
59
+ FixtureRequestOptions {
60
+ // Fixture JSON owns thinking via `template_kwargs`.
61
+ enable_thinking: None,
62
+ no_generation_prompt_when_last_assistant: false,
63
+ }
64
+}
65
+
66
+fn assert_golden(name: &str) {
67
+ let input_name = format!("{name}_input.json");
68
+ let request = fixture_chat_request(&fixture_path(&input_name), kimi_k3_fixture_options());
69
+ let rendered = render_request(&request);
70
+ expect_file![format!("fixtures/{name}_output.txt")].assert_eq(&rendered);
71
+}
72
+
73
+#[test]
74
+fn golden_history_preserve_and_image() {
75
+ assert_golden("history_preserve_and_image");
76
+}
77
+
78
+#[test]
79
+fn golden_tools_history_and_required() {
80
+ assert_golden("tools_history_and_required");
81
+}
82
+
83
+#[test]
84
+fn golden_controls_thinking_off() {
85
+ assert_golden("controls_thinking_off");
86
+}
87
+
88
+#[test]
89
+fn golden_dynamic_system_tool_declare() {
90
+ assert_golden("dynamic_system_tool_declare");
91
+}
92
+
93
+#[test]
94
+fn token_writer_protects_literal_control_and_media_markers() {
95
+ let tokenizer = Arc::new(test_tokenizer());
96
+ let user_text = format!("literal {OPEN} and {}", super::encoding::IMAGE_PLACEHOLDER);
97
+ let mut request = crate::request::ChatRequest::for_test();
98
+ request.messages = vec![ChatMessage::user(vec![
99
+ ChatContentPart::text(user_text),
100
+ ChatContentPart::image_url("data:image/png;base64,test"),
101
+ ])];
102
+ request
103
+ .chat_options
104
+ .template_kwargs
105
+ .insert("thinking".to_string(), json!(false));
106
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
107
+
108
+ let token_ids = render_token_ids(&request, tokenizer.clone());
109
+
110
+ assert_eq!(
111
+ token_ids.iter().filter(|&&token_id| token_id == OPEN_ID).count(),
112
+ 1
113
+ );
114
+ assert_eq!(
115
+ token_ids.iter().filter(|&&token_id| token_id == MEDIA_ID).count(),
116
+ 1
117
+ );
118
+
119
+ let flattened = tokenizer.decode(&token_ids, false).unwrap();
120
+ let flattened_ids = tokenizer.encode(&flattened, false).unwrap();
121
+ assert_eq!(
122
+ flattened_ids.iter().filter(|&&token_id| token_id == OPEN_ID).count(),
123
+ 2
124
+ );
125
+ assert_eq!(
126
+ flattened_ids.iter().filter(|&&token_id| token_id == MEDIA_ID).count(),
127
+ 2
128
+ );
129
+}
130
+
131
+#[test]
132
+fn thinking_history_renders_empty_think_channel() {
133
+ let mut request = crate::request::ChatRequest::for_test();
134
+ request.messages = vec![
135
+ ChatMessage::user("question"),
136
+ ChatMessage::assistant_text("answer"),
137
+ ChatMessage::user("follow-up"),
138
+ ];
139
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
140
+
141
+ let rendered = render_request(&request);
142
+
143
+ assert!(rendered.contains(
144
+ "<|open|>message role=\"assistant\"<|sep|>\
145
+ <|open|>think<|sep|><|close|>think<|sep|>\
146
+ <|open|>response<|sep|>answer<|close|>response<|sep|>"
147
+ ));
148
+}
149
+
150
+#[test]
151
+fn non_thinking_history_omits_reasoning_channel() {
152
+ let mut request = crate::request::ChatRequest::for_test();
153
+ request.messages = vec![
154
+ ChatMessage::user("question"),
155
+ ChatMessage::assistant_blocks(vec![
156
+ AssistantContentBlock::Reasoning {
157
+ text: "hidden reasoning".to_string(),
158
+ },
159
+ AssistantContentBlock::Text {
160
+ text: "answer".to_string(),
161
+ },
162
+ ]),
163
+ ChatMessage::user("follow-up"),
164
+ ];
165
+ request
166
+ .chat_options
167
+ .template_kwargs
168
+ .insert("thinking".to_string(), json!(false));
169
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
170
+
171
+ let rendered = render_request(&request);
172
+
173
+ assert!(!rendered.contains("hidden reasoning"));
174
+ assert!(!rendered.contains("<|open|>think<|sep|>"));
175
+ assert!(rendered.contains(
176
+ "<|open|>message role=\"assistant\"<|sep|>\
177
+ <|open|>response<|sep|>answer<|close|>response<|sep|>"
178
+ ));
179
+}
180
+
181
+#[test]
182
+fn defaults_thinking_effort_to_max() {
183
+ let rendered = render_request(&crate::request::ChatRequest::for_test());
184
+
185
+ expect![[r#"<|open|>message role="system" type="thinking-effort"<|sep|>`thinking_effort` guides on how much to think in your thinking channel (not including the response channel), supported values include `low`, `medium`, `high`, and `max`.
186
+Now the system is invoked with `thinking_effort=max`.<|close|>message<|sep|><|end_of_msg|><|open|>message role="user"<|sep|>test<|close|>message<|sep|><|end_of_msg|><|open|>message role="assistant"<|sep|><|open|>think<|sep|>"#]]
187
+ .assert_eq(&rendered);
188
+}
189
+
190
+#[test]
191
+fn translates_standard_thinking_kwargs() {
192
+ let mut request = crate::request::ChatRequest::for_test();
193
+ request
194
+ .chat_options
195
+ .template_kwargs
196
+ .insert("enable_thinking".to_string(), json!(true));
197
+ request
198
+ .chat_options
199
+ .template_kwargs
200
+ .insert("reasoning_effort".to_string(), json!("high"));
201
+
202
+ let rendered = render_request(&request);
203
+
204
+ assert!(rendered.contains("thinking_effort=high"));
205
+ assert!(rendered.ends_with("<|open|>think<|sep|>"));
206
+}
207
+
208
+#[test]
209
+fn native_k3_kwargs_take_precedence() {
210
+ let mut request = crate::request::ChatRequest::for_test();
211
+ request.chat_options.template_kwargs.extend([
212
+ ("thinking".to_string(), json!(true)),
213
+ ("enable_thinking".to_string(), json!(false)),
214
+ ("thinking_effort".to_string(), json!("low")),
215
+ ("reasoning_effort".to_string(), json!("high")),
216
+ ]);
217
+
218
+ let rendered = render_request(&request);
219
+
220
+ assert!(rendered.contains("thinking_effort=low"));
221
+ assert!(!rendered.contains("thinking_effort=high"));
222
+}
223
+
224
+#[test]
225
+fn standard_none_disables_thinking() {
226
+ let mut request = crate::request::ChatRequest::for_test();
227
+ request.chat_options.template_kwargs.extend([
228
+ ("enable_thinking".to_string(), json!(false)),
229
+ ("reasoning_effort".to_string(), json!("none")),
230
+ ]);
231
+
232
+ let rendered = render_request(&request);
233
+
234
+ assert!(!rendered.contains("type=\"thinking-effort\""));
235
+ assert!(rendered.ends_with("<|open|>response<|sep|>"));
236
+}
237
+
238
+#[test]
239
+fn typed_none_disables_thinking() {
240
+ let mut request = crate::request::ChatRequest::for_test();
241
+ request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
242
+
243
+ let rendered = render_request(&request);
244
+
245
+ assert!(!rendered.contains("type=\"thinking-effort\""));
246
+ assert!(rendered.ends_with("<|open|>response<|sep|>"));
247
+}
248
+
249
+#[test]
250
+fn rejects_removed_medium_thinking_effort() {
251
+ let mut request = crate::request::ChatRequest::for_test();
252
+ request
253
+ .chat_options
254
+ .template_kwargs
255
+ .insert("thinking_effort".to_string(), json!("medium"));
256
+
257
+ let error = KimiK3ChatRenderer::new(Arc::new(test_tokenizer()))
258
+ .render(&request)
259
+ .unwrap_err();
260
+
261
+ expect![[r#"
262
+ ChatTemplate(
263
+ "unsupported thinking_effort=\"medium\"; supported values are `low`, `high`, and `max`",
264
+ )
265
+ "#]]
266
+ .assert_debug_eq(&error);
267
+}
rust/src/chat/src/renderer/mod.rs
+2
@@ -15,6 +15,7 @@ pub mod deepseek_v4;
15
pub mod harmony;
16
pub mod hf;
17
mod inkling;
18
+mod kimi_k3;
19
mod selection;
20
#[cfg(test)]
21
mod test_utils;
@@ -23,6 +24,7 @@ pub use deepseek_v4::DeepSeekV4ChatRenderer;
24
pub use deepseek_v32::DeepSeekV32ChatRenderer;
25
pub use harmony::HarmonyChatRenderer;
26
pub use inkling::InklingChatRenderer;
27
+pub use kimi_k3::KimiK3ChatRenderer;
28
pub use selection::RendererSelection;
29
30
/// Rendered chat prompt submitted to the text backend.
rust/src/chat/src/renderer/selection.rs
+8
-1
@@ -26,6 +26,8 @@ pub enum RendererSelection {
26
Harmony,
27
/// Force the Inkling native token renderer.
28
Inkling,
29
+ /// Force the Kimi K3 XTML renderer.
30
+ KimiK3,
31
}
32
33
impl RendererSelection {
@@ -37,6 +39,7 @@ impl RendererSelection {
39
pub const HF_LITERAL: &str = "hf";
40
pub const INKLING_LITERAL: &str = "inkling";
41
pub const INKLING_MODEL_TYPE: &str = "inkling_mm_model";
42
+ pub const KIMI_K3_LITERAL: &str = "kimi_k3";
43
44
/// Resolve the renderer selection using the given model type string, if
45
/// it's `Auto`.
@@ -47,6 +50,7 @@ impl RendererSelection {
50
Self::DEEPSEEK_V4_LITERAL => Self::DeepSeekV4,
51
Self::GPT_OSS_MODEL_TYPE => Self::Harmony,
52
Self::INKLING_MODEL_TYPE => Self::Inkling,
53
+ Self::KIMI_K3_LITERAL => Self::KimiK3,
54
_ => Self::Hf,
55
},
56
selection => selection,
@@ -70,6 +74,8 @@ impl FromStr for RendererSelection {
74
Ok(Self::Harmony)
75
} else if value.eq_ignore_ascii_case(Self::INKLING_LITERAL) {
76
Ok(Self::Inkling)
77
+ } else if value.eq_ignore_ascii_case(Self::KIMI_K3_LITERAL) {
78
+ Ok(Self::KimiK3)
79
} else {
80
Err(format!(
81
"unknown renderer `{value}` (expected one of: {})",
@@ -88,6 +94,7 @@ impl fmt::Display for RendererSelection {
94
Self::DeepSeekV4 => f.write_str(Self::DEEPSEEK_V4_LITERAL),
95
Self::Harmony => f.write_str(Self::HARMONY_LITERAL),
96
Self::Inkling => f.write_str(Self::INKLING_LITERAL),
97
+ Self::KimiK3 => f.write_str(Self::KIMI_K3_LITERAL),
98
}
99
}
100
}
@@ -114,7 +121,7 @@ mod tests {
121
fn renderer_selection_expected_error_message() {
122
let err = RendererSelection::from_str("unknown").unwrap_err();
123
expect_test::expect![
117
- "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling)"
124
+ "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling, kimi_k3)"
125
]
126
.assert_eq(&err);
127
}
rust/src/chat/src/renderer/test_utils.rs
+41
-11
@@ -1,6 +1,7 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+use std::collections::HashMap;
5
use std::fs;
6
use std::path::Path;
7
@@ -16,8 +17,9 @@ use crate::request::{
17
/// Options for constructing a [`ChatRequest`] from a fixture file.
18
#[derive(Debug, Clone, Copy)]
19
pub(crate) struct FixtureRequestOptions {
19
- /// Whether to set the template kwarg `[enable_]thinking=true`.
20
- pub enable_thinking: bool,
20
+ /// Optional thinking toggle applied only when the fixture does not already
21
+ /// set `thinking` / `enable_thinking` in [`FixtureRequest::template_kwargs`].
22
+ pub enable_thinking: Option<bool>,
23
/// Whether fixtures ending in an assistant message should omit the
24
/// trailing generation prompt.
25
pub no_generation_prompt_when_last_assistant: bool,
@@ -46,6 +48,15 @@ pub(crate) struct FixtureRequest {
48
messages: Vec<FixtureMessage>,
49
add_generation_prompt: Option<bool>,
50
reasoning_effort: Option<ReasoningEffort>,
51
+ /// Standard response format passed to model-specific renderers.
52
+ #[serde(default)]
53
+ response_format: Option<Value>,
54
+ /// Extra chat-template kwargs (thinking, preserve_thinking, …).
55
+ #[serde(default)]
56
+ template_kwargs: HashMap<String, Value>,
57
+ /// When omitted, defaults to `auto` if tools are present, otherwise `none`.
58
+ #[serde(default)]
59
+ tool_choice: Option<ChatToolChoice>,
60
}
61
62
impl FixtureFile {
@@ -57,6 +68,9 @@ impl FixtureFile {
68
messages,
69
add_generation_prompt: None,
70
reasoning_effort: None,
71
+ response_format: None,
72
+ template_kwargs: HashMap::new(),
73
+ tool_choice: None,
74
},
75
}
76
}
@@ -69,6 +83,7 @@ pub(crate) enum FixtureMessage {
83
content: FixtureContent,
84
},
85
Developer {
86
+ #[serde(default)]
87
content: FixtureContent,
88
#[serde(default)]
89
tools: Vec<FixtureTool>,
@@ -98,6 +113,12 @@ pub(crate) enum FixtureContent {
113
Parts(Vec<FixtureContentPart>),
114
}
115
116
+impl Default for FixtureContent {
117
+ fn default() -> Self {
118
+ Self::Text(String::new())
119
+ }
120
+}
121
+
122
#[derive(Debug, Deserialize)]
123
#[serde(tag = "type", rename_all = "snake_case")]
124
pub(crate) enum FixtureContentPart {
@@ -136,6 +157,13 @@ struct FixtureToolCallFunction {
157
158
impl FixtureRequest {
159
fn into_chat_request(self, options: FixtureRequestOptions) -> ChatRequest {
160
+ let tools = to_chat_tools(&self.tools);
161
+ let tool_choice = self.tool_choice.unwrap_or(if tools.is_empty() {
162
+ ChatToolChoice::None
163
+ } else {
164
+ ChatToolChoice::Auto
165
+ });
166
+
167
let mut request = ChatRequest {
168
request_id: "renderer-fixture".to_string(),
169
messages: self
@@ -144,12 +172,8 @@ impl FixtureRequest {
172
.enumerate()
173
.map(|(index, message)| fixture_message_to_chat_message(index, message))
174
.collect(),
147
- tools: to_chat_tools(&self.tools),
148
- tool_choice: if self.tools.is_empty() {
149
- ChatToolChoice::None
150
- } else {
151
- ChatToolChoice::Auto
152
- },
175
+ tools,
176
+ tool_choice,
177
..ChatRequest::for_test()
178
};
179
@@ -162,9 +186,15 @@ impl FixtureRequest {
186
request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
187
}
188
request.chat_options.reasoning_effort = self.reasoning_effort;
165
- if options.enable_thinking {
166
- for key in ["thinking", "enable_thinking"] {
167
- request.chat_options.template_kwargs.insert(key.to_string(), Value::Bool(true));
189
+ request.chat_options.response_format = self.response_format;
190
+ request.chat_options.template_kwargs.extend(self.template_kwargs);
191
+
192
+ // Options supply a default thinking toggle only when the fixture did not.
193
+ if let Some(thinking) = options.enable_thinking {
194
+ let kwargs = &mut request.chat_options.template_kwargs;
195
+ if !kwargs.contains_key("thinking") && !kwargs.contains_key("enable_thinking") {
196
+ kwargs.insert("thinking".to_string(), Value::Bool(thinking));
197
+ kwargs.insert("enable_thinking".to_string(), Value::Bool(thinking));
198
}
199
}
200
rust/src/chat/src/request.rs
+5
@@ -397,6 +397,10 @@ pub struct ChatOptions {
397
/// Effort level exposed to chat templates for reasoning models.
398
pub reasoning_effort: Option<ReasoningEffort>,
399
400
+ /// Standard response format available to model-specific renderers.
401
+ #[serde(default)]
402
+ pub response_format: Option<Value>,
403
+
404
/// Additional keyword arguments exposed to the chat template.
405
pub template_kwargs: HashMap<String, Value>,
406
}
@@ -407,6 +411,7 @@ impl Default for ChatOptions {
411
generation_prompt_mode: GenerationPromptMode::StartNewAssistant,
412
chat_template: None,
413
reasoning_effort: None,
414
+ response_format: None,
415
template_kwargs: HashMap::new(),
416
}
417
}
rust/src/chat/tests/roundtrip.rs
+35
-7
@@ -212,6 +212,23 @@ impl RoundtripCase {
212
}
213
}
214
215
+ /// Kimi K3 XTML tool/reasoning channels (native renderer + unified parser).
216
+ ///
217
+ /// Needs HF tokenizer files under `HF_HOME` (`tiktoken.model` +
218
+ /// `tokenizer_config.json`). Weights are not required for this text-level
219
+ /// roundtrip.
220
+ fn kimi_k3() -> Self {
221
+ Self {
222
+ model_id: "moonshotai/Kimi-K3",
223
+ assistant_stop_suffix: "<|end_of_msg|>",
224
+ tool_call_parser: ParserSelection::Auto,
225
+ reasoning_parser: ParserSelection::Auto,
226
+ thinking_behavior: ThinkingBehavior::Toggleable { default: true },
227
+ json_fmt: compact_json_fmt(),
228
+ sort_json_keys: false,
229
+ }
230
+ }
231
+
232
/// SeedOSS with `<seed:think>` / `</seed:think>` reasoning tags.
233
fn seed_oss() -> Self {
234
Self {
@@ -255,7 +272,7 @@ impl RoundtripCase {
272
fn gpt_oss() -> Self {
273
Self {
274
model_id: "openai/gpt-oss-20b",
258
- assistant_stop_suffix: "", // not applicable for token-id cases
275
+ assistant_stop_suffix: "",
276
tool_call_parser: ParserSelection::Auto,
277
reasoning_parser: ParserSelection::Auto,
278
thinking_behavior: ThinkingBehavior::Always { value: true },
@@ -268,7 +285,7 @@ impl RoundtripCase {
285
fn inkling() -> Self {
286
Self {
287
model_id: "thinkingmachines/Inkling",
271
- assistant_stop_suffix: "",
288
+ assistant_stop_suffix: "<|content_model_end_sampling|>",
289
tool_call_parser: ParserSelection::Auto,
290
reasoning_parser: ParserSelection::Auto,
291
thinking_behavior: ThinkingBehavior::Always { value: true },
@@ -312,6 +329,8 @@ roundtrip_tests! {
329
nemotron_v3 => [reasoning_and_content],
330
gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call
331
kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history
332
+ // K3 drops plain-assistant reasoning in history; tool-call turns keep it.
333
+ kimi_k3 => [tool_call_mix],
334
gpt_oss => [tool_call_mix], // Harmony strips reasoning in history if there's no tool call
335
inkling => [reasoning_and_content, tool_call_mix],
336
}
@@ -670,14 +689,23 @@ fn decoded_completion_stream(
689
.collect()
690
}
691
Prompt::TokenIds(token_ids) => {
673
- ensure!(
674
- assistant_stop_suffix.is_empty(),
675
- "token-id roundtrip cases do not support text stop suffixes"
676
- );
692
+ let body = if assistant_stop_suffix.is_empty() {
693
+ token_ids.as_slice()
694
+ } else {
695
+ let stop_token_ids = tokenizer
696
+ .encode(assistant_stop_suffix, false)
697
+ .context("failed to encode token-id completion stop suffix")?;
698
+ token_ids.strip_suffix(stop_token_ids.as_slice()).with_context(|| {
699
+ format!(
700
+ "token-id completion did not end with {:?}: {:?}",
701
+ assistant_stop_suffix, token_ids
702
+ )
703
+ })?
704
+ };
705
incremental_decode_chunks(
706
tokenizer,
707
&prompt_token_ids,
680
- token_ids,
708
+ body,
709
TOKEN_COMPLETION_CHUNK_TOKENS,
710
)?
711
}
rust/src/cmd/src/cli/tests.rs
+1
-1
@@ -666,7 +666,7 @@ fn serve_args_reject_unknown_renderer_value() {
666
.unwrap_err();
667
668
expect![[r#"
669
- error: invalid value 'definitely_missing' for '--tokenizer-mode <RENDERER>': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling)
669
+ error: invalid value 'definitely_missing' for '--tokenizer-mode <RENDERER>': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling, kimi_k3)
670
671
For more information, try '--help'.
672
"#]]
rust/src/parser/src/unified/kimi_k3.rs
new
+1149
@@ -0,0 +1,1149 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Unified parser for the Kimi K3 (XTML) chat format.
5
+//!
6
+//! Original Python implementations:
7
+//! - `vllm/reasoning/kimi_k3_reasoning_parser.py`
8
+//! - `vllm/tool_parsers/kimi_k3_tool_parser.py`
9
+//!
10
+//! K3 wraps one assistant message into XTML channels built from the dedicated
11
+//! special tokens `<|open|>`, `<|close|>`, and `<|sep|>`:
12
+//!
13
+//! ```text
14
+//! <|open|>think<|sep|>reasoning<|close|>think<|sep|>
15
+//! <|open|>response<|sep|>visible answer<|close|>response<|sep|>
16
+//! <|open|>tools<|sep|>
17
+//! <|open|>call tool="get_weather" index="1"<|sep|>
18
+//! <|open|>argument key="city" type="string"<|sep|>Hangzhou<|close|>argument<|sep|>
19
+//! <|close|>call<|sep|>
20
+//! <|close|>tools<|sep|>
21
+//! <|close|>message<|sep|>
22
+//! ```
23
+//!
24
+//! In chat serving the generation prompt ends with `<|open|>think<|sep|>`
25
+//! (thinking) or `<|open|>response<|sep|>` (instruct), so the model output
26
+//! starts *inside* that channel without re-emitting the open tag;
27
+//! [`UnifiedParser::initialize`] detects this from the prompt token IDs.
28
+//!
29
+//! Argument decoding mirrors the renderer's type tagging (inverse encoding):
30
+//! `type="string"` values pass the raw text through, other types are
31
+//! JSON-decoded, and a raw `json` block is passed through unmodified. Attribute
32
+//! values reverse the renderer escaping (`"` before `&`).
33
+//!
34
+//! Known limitation (shared with the Python parser): string argument and
35
+//! response bodies are emitted raw, so a value that literally contains
36
+//! `<|close|>argument<|sep|>` or `<|close|>response<|sep|>` is
37
+//! indistinguishable from a real closing marker.
38
+
39
+mod structural_tag;
40
+
41
+pub use structural_tag::KimiK3StructuralTagBuilder;
42
+
43
+use serde_json::{Map, Value};
44
+use vllm_tokenizer::DynTokenizer;
45
+use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
46
+use winnow::combinator::{alt, delimited, eof, preceded, repeat, seq, terminated};
47
+use winnow::error::{ContextError, ErrMode, ModalResult, StrContext};
48
+use winnow::prelude::*;
49
+use winnow::stream::Partial;
50
+use winnow::token::{literal, rest, take_till, take_until, take_while};
51
+
52
+use self::structural_tag::KIMI_K3_STRUCTURAL_TAG_BUILDER;
53
+use super::{Result, UnifiedParser, UnifiedParserOutput, token_id};
54
+use crate::tool::{StructuralTagBuilder, Tool, ToolCallDelta};
55
+use crate::unified::parsing_failed;
56
+use crate::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
57
+
58
+const OPEN: &str = "<|open|>";
59
+const SEP: &str = "<|sep|>";
60
+const END_OF_MSG: &str = "<|end_of_msg|>";
61
+
62
+const THINK_OPEN: &str = "<|open|>think<|sep|>";
63
+const THINK_CLOSE: &str = "<|close|>think<|sep|>";
64
+const RESPONSE_OPEN: &str = "<|open|>response<|sep|>";
65
+const RESPONSE_CLOSE: &str = "<|close|>response<|sep|>";
66
+const TOOLS_OPEN: &str = "<|open|>tools<|sep|>";
67
+const TOOLS_CLOSE: &str = "<|close|>tools<|sep|>";
68
+const MESSAGE_CLOSE: &str = "<|close|>message<|sep|>";
69
+const CALL_OPEN: &str = "<|open|>call";
70
+const CALL_CLOSE: &str = "<|close|>call<|sep|>";
71
+const ARG_OPEN: &str = "<|open|>argument";
72
+const ARG_CLOSE: &str = "<|close|>argument<|sep|>";
73
+const JSON_OPEN: &str = "<|open|>json";
74
+const JSON_CLOSE: &str = "<|close|>json<|sep|>";
75
+
76
+const IDLE_MARKERS: &[&str] = &[
77
+ THINK_OPEN,
78
+ RESPONSE_OPEN,
79
+ TOOLS_OPEN,
80
+ MESSAGE_CLOSE,
81
+ END_OF_MSG,
82
+];
83
+const REASONING_MARKERS: &[&str] = &[THINK_CLOSE, END_OF_MSG];
84
+const RESPONSE_MARKERS: &[&str] = &[RESPONSE_CLOSE, TOOLS_OPEN, MESSAGE_CLOSE, END_OF_MSG];
85
+const EPILOGUE_MARKERS: &[&str] = &[TOOLS_OPEN, MESSAGE_CLOSE, END_OF_MSG];
86
+const TOOLS_MARKERS: &[&str] = &[CALL_OPEN, TOOLS_CLOSE, MESSAGE_CLOSE, END_OF_MSG];
87
+
88
+/// Channel tags are a couple of text tokens; longer `<|open|>…<|sep|>` spans in
89
+/// the prompt tail (attribute-bearing message opens, message bodies) never name
90
+/// a generation channel.
91
+const MAX_PREFILL_TAG_TOKENS: usize = 8;
92
+
93
+type KimiK3Input<'i> = Partial<&'i str>;
94
+
95
+#[derive(Debug, Clone, PartialEq, Eq)]
96
+enum KimiK3Event {
97
+ Text {
98
+ len: usize,
99
+ },
100
+ Reasoning {
101
+ len: usize,
102
+ },
103
+ /// Structural noise consumed without emitting anything.
104
+ Skip,
105
+ ThinkOpen,
106
+ ThinkClose,
107
+ ResponseOpen,
108
+ ResponseClose,
109
+ ToolsOpen,
110
+ ToolsClose,
111
+ /// The assistant message closed; everything after it is ignored.
112
+ MessageEnd,
113
+ CallOpen {
114
+ name: String,
115
+ index: Option<String>,
116
+ },
117
+ CallComplete {
118
+ arguments: String,
119
+ },
120
+}
121
+
122
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
123
+enum KimiK3Mode {
124
+ /// Before any channel opens: channel opens are expected, but raw text
125
+ /// falls through as visible text so marker-free output still streams.
126
+ #[default]
127
+ Idle,
128
+ /// Inside the `think` channel.
129
+ Reasoning,
130
+ /// Inside the `response` channel.
131
+ Response,
132
+ /// After the `response` (or `tools`) channel closed: only a `tools`
133
+ /// channel or the message close may follow, and noise is never content.
134
+ Epilogue,
135
+ /// Inside the `tools` channel, between `call` blocks.
136
+ Tools,
137
+ /// Inside one `call` block, buffering its body until the close marker.
138
+ Call {
139
+ name: String,
140
+ index: Option<String>,
141
+ scan: MarkerScanState,
142
+ },
143
+ /// After the message closed: ignore the rest (EOS leakage guard).
144
+ Done,
145
+}
146
+
147
+/// Unified parser for Kimi K3 XTML think / response / tools channels.
148
+pub struct KimiK3UnifiedParser {
149
+ buffer: String,
150
+ mode: KimiK3Mode,
151
+ /// Parser-provided tool-call IDs (`{tool}:{zero_based_index}`) by tool
152
+ /// index; its length is also the count of emitted calls.
153
+ call_ids: Vec<String>,
154
+ tokenizer: DynTokenizer,
155
+ open_token_id: u32,
156
+ sep_token_id: u32,
157
+}
158
+
159
+impl KimiK3UnifiedParser {
160
+ /// Create a Kimi K3 parser.
161
+ pub fn new(_tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
162
+ let open_token_id = token_id(tokenizer.as_ref(), OPEN)?;
163
+ let sep_token_id = token_id(tokenizer.as_ref(), SEP)?;
164
+
165
+ Ok(Self {
166
+ buffer: String::new(),
167
+ mode: KimiK3Mode::default(),
168
+ call_ids: Vec::new(),
169
+ tokenizer,
170
+ open_token_id,
171
+ sep_token_id,
172
+ })
173
+ }
174
+
175
+ /// Detect the prefilled generation channel from the prompt tail.
176
+ ///
177
+ /// `add_generation_prompt` ends the prompt with `<|open|>think<|sep|>` or
178
+ /// `<|open|>response<|sep|>`, so generation starts inside that channel and
179
+ /// never re-emits the open tag. Locate the last `<|open|>…<|sep|>` pair and
180
+ /// decode the short tag between the two structural tokens.
181
+ fn initialize_mode(&mut self, prompt_token_ids: &[u32]) {
182
+ self.mode = KimiK3Mode::Idle;
183
+
184
+ let Some(sep_pos) = prompt_token_ids.iter().rposition(|&id| id == self.sep_token_id) else {
185
+ return;
186
+ };
187
+ let Some(open_pos) =
188
+ prompt_token_ids[..sep_pos].iter().rposition(|&id| id == self.open_token_id)
189
+ else {
190
+ return;
191
+ };
192
+ let tag_ids = &prompt_token_ids[open_pos + 1..sep_pos];
193
+ if tag_ids.is_empty() || tag_ids.len() > MAX_PREFILL_TAG_TOKENS {
194
+ return;
195
+ }
196
+ let Ok(tag) = self.tokenizer.decode(tag_ids, /* skip_special_tokens */ false) else {
197
+ return;
198
+ };
199
+ self.mode = match tag.trim() {
200
+ "think" => KimiK3Mode::Reasoning,
201
+ "response" => KimiK3Mode::Response,
202
+ _ => KimiK3Mode::Idle,
203
+ };
204
+ }
205
+
206
+ fn apply_event(&mut self, event: KimiK3Event, output: &mut UnifiedParserOutput) -> Result<()> {
207
+ match event {
208
+ KimiK3Event::Text { len } => output.push_text(self.buffer[..len].to_string()),
209
+ KimiK3Event::Reasoning { len } => {
210
+ output.push_reasoning(self.buffer[..len].to_string());
211
+ }
212
+ KimiK3Event::Skip => {}
213
+ KimiK3Event::ThinkOpen => self.mode = KimiK3Mode::Reasoning,
214
+ KimiK3Event::ThinkClose => self.mode = KimiK3Mode::Idle,
215
+ KimiK3Event::ResponseOpen => self.mode = KimiK3Mode::Response,
216
+ KimiK3Event::ResponseClose => self.mode = KimiK3Mode::Epilogue,
217
+ KimiK3Event::ToolsOpen => self.mode = KimiK3Mode::Tools,
218
+ KimiK3Event::ToolsClose => self.mode = KimiK3Mode::Epilogue,
219
+ KimiK3Event::MessageEnd => self.mode = KimiK3Mode::Done,
220
+ KimiK3Event::CallOpen { name, index } => {
221
+ self.mode = KimiK3Mode::Call {
222
+ name,
223
+ index,
224
+ scan: MarkerScanState::default(),
225
+ };
226
+ }
227
+ KimiK3Event::CallComplete { arguments } => {
228
+ let mode = std::mem::replace(&mut self.mode, KimiK3Mode::Tools);
229
+ let KimiK3Mode::Call { name, index, .. } = mode else {
230
+ return Err(parsing_failed!(
231
+ "Kimi K3 call completion without an active tool call"
232
+ ));
233
+ };
234
+ // An empty/garbage call block without a tool name is dropped,
235
+ // matching the Python parser.
236
+ if name.is_empty() {
237
+ return Ok(());
238
+ }
239
+
240
+ let tool_index = self.call_ids.len();
241
+ self.call_ids.push(tool_call_id_for(&name, index.as_deref()));
242
+ output.push_call(ToolCallDelta {
243
+ tool_index,
244
+ name: Some(name),
245
+ arguments,
246
+ });
247
+ }
248
+ }
249
+ Ok(())
250
+ }
251
+
252
+ fn reset_state(&mut self) -> String {
253
+ self.mode = KimiK3Mode::Idle;
254
+ self.call_ids.clear();
255
+ std::mem::take(&mut self.buffer)
256
+ }
257
+}
258
+
259
+impl UnifiedParser for KimiK3UnifiedParser {
260
+ fn create(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Box<dyn UnifiedParser>>
261
+ where
262
+ Self: Sized + 'static,
263
+ {
264
+ Self::new(tools, tokenizer).map(|parser| Box::new(parser) as Box<dyn UnifiedParser>)
265
+ }
266
+
267
+ fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
268
+ self.buffer.clear();
269
+ self.call_ids.clear();
270
+ self.initialize_mode(prompt_token_ids);
271
+ Ok(())
272
+ }
273
+
274
+ fn preserve_special_tokens(&self) -> bool {
275
+ true
276
+ }
277
+
278
+ fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
279
+ Some(&KIMI_K3_STRUCTURAL_TAG_BUILDER)
280
+ }
281
+
282
+ fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
283
+ self.call_ids.get(tool_index).map(String::as_str)
284
+ }
285
+
286
+ fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> {
287
+ self.buffer.push_str(chunk);
288
+
289
+ while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
290
+ parse_next_kimi_k3_event(input, &mut self.mode)
291
+ })? {
292
+ self.apply_event(event, output)?;
293
+ self.buffer.drain(..consumed_len);
294
+ }
295
+
296
+ Ok(())
297
+ }
298
+
299
+ fn finish(&mut self) -> Result<UnifiedParserOutput> {
300
+ let mut output = UnifiedParserOutput::default();
301
+
302
+ match &self.mode {
303
+ KimiK3Mode::Idle | KimiK3Mode::Response => {
304
+ output.push_text(std::mem::take(&mut self.buffer));
305
+ }
306
+ KimiK3Mode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)),
307
+ KimiK3Mode::Epilogue | KimiK3Mode::Done => self.buffer.clear(),
308
+ // A tools channel truncated between complete calls loses only its
309
+ // closing markers; keep the calls already emitted.
310
+ KimiK3Mode::Tools if self.buffer.is_empty() => {}
311
+ KimiK3Mode::Tools | KimiK3Mode::Call { .. } => {
312
+ return Err(parsing_failed!("incomplete Kimi K3 tool call"));
313
+ }
314
+ }
315
+
316
+ // Keep call_ids so tool_call_id() stays available after the stream ends.
317
+ self.mode = KimiK3Mode::Idle;
318
+ Ok(output)
319
+ }
320
+
321
+ fn reset(&mut self) -> String {
322
+ self.reset_state()
323
+ }
324
+}
325
+
326
+/// Build the API-side tool-call ID from the XTML one-based `index` attribute.
327
+///
328
+/// The ID uses the zero-based call ordinal; XTML's message index stays
329
+/// one-based when rendering tool result messages.
330
+fn tool_call_id_for(name: &str, index: Option<&str>) -> String {
331
+ match index {
332
+ None => name.to_string(),
333
+ Some(raw) => match raw.parse::<i64>() {
334
+ Ok(one_based) => format!("{name}:{}", one_based - 1),
335
+ Err(_) => format!("{name}:{raw}"),
336
+ },
337
+ }
338
+}
339
+
340
+/// Parse one Kimi K3 event from buffered streaming input.
341
+fn parse_next_kimi_k3_event(
342
+ input: &mut KimiK3Input<'_>,
343
+ mode: &mut KimiK3Mode,
344
+) -> ModalResult<KimiK3Event> {
345
+ match mode {
346
+ KimiK3Mode::Idle => parse_idle_event(input),
347
+ KimiK3Mode::Reasoning => parse_reasoning_event(input),
348
+ KimiK3Mode::Response => parse_response_event(input),
349
+ KimiK3Mode::Epilogue => parse_epilogue_event(input),
350
+ KimiK3Mode::Tools => parse_tools_event(input),
351
+ KimiK3Mode::Call { scan, .. } => call_body_event(input, scan),
352
+ KimiK3Mode::Done => parse_done_event(input),
353
+ }
354
+}
355
+
356
+/// Parse an event while waiting for the next channel open.
357
+fn parse_idle_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
358
+ alt((
359
+ literal(THINK_OPEN).value(KimiK3Event::ThinkOpen),
360
+ literal(RESPONSE_OPEN).value(KimiK3Event::ResponseOpen),
361
+ literal(TOOLS_OPEN).value(KimiK3Event::ToolsOpen),
362
+ message_end_event,
363
+ safe_idle_text_event,
364
+ ))
365
+ .parse_next(input)
366
+}
367
+
368
+/// Parse an event inside the `think` channel.
369
+fn parse_reasoning_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
370
+ alt((
371
+ literal(THINK_CLOSE).value(KimiK3Event::ThinkClose),
372
+ // `<|end_of_msg|>` can reach the parser under `ignore_eos` or
373
+ // `include_stop_str_in_output`; never leak it into reasoning.
374
+ literal(END_OF_MSG).value(KimiK3Event::MessageEnd),
375
+ safe_reasoning_event,
376
+ ))
377
+ .parse_next(input)
378
+}
379
+
380
+/// Parse an event inside the `response` channel.
381
+fn parse_response_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
382
+ alt((
383
+ literal(RESPONSE_CLOSE).value(KimiK3Event::ResponseClose),
384
+ // The response body also implicitly ends at a `tools` channel.
385
+ literal(TOOLS_OPEN).value(KimiK3Event::ToolsOpen),
386
+ message_end_event,
387
+ safe_response_text_event,
388
+ ))
389
+ .parse_next(input)
390
+}
391
+
392
+/// Parse an event after the response channel closed.
393
+fn parse_epilogue_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
394
+ alt((
395
+ literal(TOOLS_OPEN).value(KimiK3Event::ToolsOpen),
396
+ message_end_event,
397
+ skip_epilogue_noise_event,
398
+ ))
399
+ .parse_next(input)
400
+}
401
+
402
+/// Parse an event inside the `tools` channel, between `call` blocks.
403
+fn parse_tools_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
404
+ alt((
405
+ call_open_event,
406
+ literal(TOOLS_CLOSE).value(KimiK3Event::ToolsClose),
407
+ // Defensive: an unterminated tools channel still ends with the message.
408
+ message_end_event,
409
+ skip_tools_noise_event,
410
+ ))
411
+ .parse_next(input)
412
+}
413
+
414
+/// Parse a message close or end-of-message marker.
415
+fn message_end_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
416
+ alt((literal(MESSAGE_CLOSE), literal(END_OF_MSG)))
417
+ .value(KimiK3Event::MessageEnd)
418
+ .parse_next(input)
419
+}
420
+
421
+/// Ignore everything after the assistant message closed.
422
+fn parse_done_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
423
+ rest.value(KimiK3Event::Skip).parse_next(input)
424
+}
425
+
426
+/// Parse safe text while waiting for the next channel marker.
427
+fn safe_idle_text_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
428
+ safe_text_len_mul(input, IDLE_MARKERS).map(|len| KimiK3Event::Text { len })
429
+}
430
+
431
+/// Parse safe reasoning before the think close marker.
432
+fn safe_reasoning_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
433
+ safe_text_len_mul(input, REASONING_MARKERS).map(|len| KimiK3Event::Reasoning { len })
434
+}
435
+
436
+/// Parse safe response text before the next channel marker.
437
+fn safe_response_text_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
438
+ safe_text_len_mul(input, RESPONSE_MARKERS).map(|len| KimiK3Event::Text { len })
439
+}
440
+
441
+/// Skip non-content noise after the response channel closed.
442
+fn skip_epilogue_noise_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
443
+ safe_text_len_mul(input, EPILOGUE_MARKERS).map(|_| KimiK3Event::Skip)
444
+}
445
+
446
+/// Skip non-content noise between `call` blocks.
447
+fn skip_tools_noise_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
448
+ safe_text_len_mul(input, TOOLS_MARKERS).map(|_| KimiK3Event::Skip)
449
+}
450
+
451
+/// Parse a `call` open tag into its tool name and one-based index.
452
+fn call_open_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
453
+ let (attrs,) = seq!(
454
+ _: literal(CALL_OPEN),
455
+ take_until(0.., SEP),
456
+ _: literal(SEP),
457
+ )
458
+ .parse_next(input)?;
459
+ let attrs = parse_tag_attrs(attrs)?;
460
+
461
+ Ok(KimiK3Event::CallOpen {
462
+ name: attr_value(&attrs, "tool").unwrap_or_default().to_string(),
463
+ index: attr_value(&attrs, "index")
464
+ .filter(|index| !index.is_empty())
465
+ .map(str::to_string),
466
+ })
467
+}
468
+
469
+/// Parse the buffered call body through `<|close|>call<|sep|>` into a
470
+/// completed call.
471
+fn call_body_event(
472
+ input: &mut KimiK3Input<'_>,
473
+ scan: &mut MarkerScanState,
474
+) -> ModalResult<KimiK3Event> {
475
+ let (body,) = seq!(
476
+ take_until_marker(CALL_CLOSE, scan),
477
+ _: literal(CALL_CLOSE),
478
+ )
479
+ .parse_next(input)?;
480
+ let arguments = parse_call_arguments(body)?;
481
+
482
+ Ok(KimiK3Event::CallComplete { arguments })
483
+}
484
+
485
+/// Parse a complete `call` body into the OpenAI-style arguments JSON string.
486
+///
487
+/// The body is either one raw `json` block (passed through unmodified) or a
488
+/// sequence of typed `argument` blocks (converted per their `type` tags).
489
+fn parse_call_arguments(body: &str) -> ModalResult<String> {
490
+ let mut input = body;
491
+ terminated(
492
+ delimited(ws0, alt((json_block_arguments, typed_arguments)), ws0),
493
+ eof,
494
+ )
495
+ .parse_next(&mut input)
496
+ .map_err(|_| xtml_error("Kimi K3 call body"))
497
+}
498
+
499
+/// Parse one raw `json` argument block, passing its body through unmodified.
500
+fn json_block_arguments(input: &mut &str) -> ModalResult<String> {
501
+ let (raw,) = seq!(
502
+ _: literal(JSON_OPEN),
503
+ _: take_until(0.., SEP), // attrs (`type="object"`), unused on decode
504
+ _: literal(SEP),
505
+ take_until(0.., JSON_CLOSE),
506
+ _: literal(JSON_CLOSE),
507
+ )
508
+ .parse_next(input)?;
509
+ Ok(raw.to_string())
510
+}
511
+
512
+/// Parse typed `argument` blocks into a serialized JSON object, preserving
513
+/// argument order.
514
+fn typed_arguments(input: &mut &str) -> ModalResult<String> {
515
+ let pairs: Vec<(String, Value)> =
516
+ repeat(0.., terminated(argument_block, ws0)).parse_next(input)?;
517
+ let arguments = pairs.into_iter().collect::<Map<String, Value>>();
518
+ serde_json::to_string(&arguments).map_err(|_| xtml_error("Kimi K3 arguments"))
519
+}
520
+
521
+/// Parse one typed `argument` block into its key/value pair.
522
+fn argument_block(input: &mut &str) -> ModalResult<(String, Value)> {
523
+ let (attrs, raw) = seq!(
524
+ _: literal(ARG_OPEN),
525
+ take_until(0.., SEP),
526
+ _: literal(SEP),
527
+ take_until(0.., ARG_CLOSE),
528
+ _: literal(ARG_CLOSE),
529
+ )
530
+ .parse_next(input)?;
531
+ let attrs = parse_tag_attrs(attrs)?;
532
+
533
+ let key = attr_value(&attrs, "key").unwrap_or_default().to_string();
534
+ let arg_type = attr_value(&attrs, "type").unwrap_or("string");
535
+ Ok((key, decode_argument_value(arg_type, raw)))
536
+}
537
+
538
+/// Decode one typed argument value per its XTML `type` tag.
539
+///
540
+/// `string` values pass the raw text through (the renderer emits them
541
+/// unescaped); other types are JSON-decoded, falling back to the raw text on
542
+/// malformed payloads so one quirky value does not fail the whole call.
543
+fn decode_argument_value(arg_type: &str, raw: &str) -> Value {
544
+ if arg_type == "string" {
545
+ return Value::String(raw.to_string());
546
+ }
547
+ serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
548
+}
549
+
550
+/// Parse a complete XTML attribute string like ` tool="get_weather" index="1"`.
551
+fn parse_tag_attrs(attrs: &str) -> ModalResult<Vec<(String, String)>> {
552
+ let mut input = attrs;
553
+ terminated(repeat(0.., preceded(ws1, tag_attr)), (ws0, eof))
554
+ .parse_next(&mut input)
555
+ .map_err(|_| xtml_error("XTML tag attributes"))
556
+}
557
+
558
+/// Parse one XTML `key="value"` attribute pair.
559
+fn tag_attr(input: &mut &str) -> ModalResult<(String, String)> {
560
+ seq!(
561
+ take_while(1.., |char: char| char.is_alphanumeric() || char == '_').map(str::to_string),
562
+ _: literal("=\""),
563
+ take_till(0.., '"').map(unescape_attr_value),
564
+ _: literal("\""),
565
+ )
566
+ .parse_next(input)
567
+}
568
+
569
+/// Reverse XTML attribute escaping: `"` first, then `&` (the inverse
570
+/// of the encode order).
571
+fn unescape_attr_value(value: &str) -> String {
572
+ value.replace(""", "\"").replace("&", "&")
573
+}
574
+
575
+/// Look up one parsed attribute value by key.
576
+fn attr_value<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> {
577
+ attrs.iter().find(|(name, _)| name == key).map(|(_, value)| value.as_str())
578
+}
579
+
580
+/// Build a cut error for determinably malformed XTML structure.
581
+fn xtml_error(label: &'static str) -> ErrMode<ContextError> {
582
+ let mut error = ContextError::new();
583
+ error.push(StrContext::Label(label));
584
+ ErrMode::Cut(error)
585
+}
586
+
587
+#[cfg(test)]
588
+mod tests {
589
+ use std::sync::Arc;
590
+
591
+ use serde_json::{Value, json};
592
+ use thiserror_ext::AsReport;
593
+ use vllm_tokenizer::Tokenizer as _;
594
+ use vllm_tokenizer::test_utils::TestTokenizer;
595
+
596
+ use super::{
597
+ END_OF_MSG, KimiK3UnifiedParser, OPEN, RESPONSE_CLOSE, RESPONSE_OPEN, SEP, THINK_CLOSE,
598
+ THINK_OPEN, TOOLS_CLOSE, TOOLS_OPEN,
599
+ };
600
+ use crate::tool::ToolCallDelta;
601
+ use crate::unified::{
602
+ UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput,
603
+ };
604
+
605
+ const OPEN_ID: u32 = 256;
606
+ const CLOSE_ID: u32 = 257;
607
+ const SEP_ID: u32 = 258;
608
+ const END_OF_MSG_ID: u32 = 259;
609
+
610
+ fn tokenizer() -> TestTokenizer {
611
+ TestTokenizer::new()
612
+ .with_special_token(OPEN, OPEN_ID)
613
+ .with_special_token("<|close|>", CLOSE_ID)
614
+ .with_special_token(SEP, SEP_ID)
615
+ .with_special_token(END_OF_MSG, END_OF_MSG_ID)
616
+ }
617
+
618
+ trait UnifiedParserTestExt {
619
+ fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput>;
620
+ fn parse_complete(&mut self, text: &str) -> super::Result<UnifiedParserOutput>;
621
+ }
622
+
623
+ impl UnifiedParserTestExt for KimiK3UnifiedParser {
624
+ fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput> {
625
+ let mut output = UnifiedParserOutput::default();
626
+ self.parse_into(chunk, &mut output)?;
627
+ Ok(output)
628
+ }
629
+
630
+ fn parse_complete(&mut self, text: &str) -> super::Result<UnifiedParserOutput> {
631
+ let mut output = self.parse_chunk(text)?;
632
+ output.append(self.finish()?);
633
+ Ok(output)
634
+ }
635
+ }
636
+
637
+ trait UnifiedOutputTestExt {
638
+ fn normal_text(&self) -> String;
639
+ fn reasoning_text(&self) -> String;
640
+ fn calls(&self) -> Vec<ToolCallDelta>;
641
+ }
642
+
643
+ impl UnifiedOutputTestExt for UnifiedParserOutput {
644
+ fn normal_text(&self) -> String {
645
+ self.events
646
+ .iter()
647
+ .filter_map(|event| match event {
648
+ UnifiedParserEvent::Text(text) => Some(text.as_str()),
649
+ _ => None,
650
+ })
651
+ .collect()
652
+ }
653
+
654
+ fn reasoning_text(&self) -> String {
655
+ self.events
656
+ .iter()
657
+ .filter_map(|event| match event {
658
+ UnifiedParserEvent::Reasoning(text) => Some(text.as_str()),
659
+ _ => None,
660
+ })
661
+ .collect()
662
+ }
663
+
664
+ fn calls(&self) -> Vec<ToolCallDelta> {
665
+ self.events
666
+ .iter()
667
+ .filter_map(|event| match event {
668
+ UnifiedParserEvent::ToolCall(call) => Some(call.clone()),
669
+ _ => None,
670
+ })
671
+ .collect()
672
+ }
673
+ }
674
+
675
+ fn test_parser() -> KimiK3UnifiedParser {
676
+ KimiK3UnifiedParser::new(&[], Arc::new(tokenizer())).unwrap()
677
+ }
678
+
679
+ fn collect_stream(parser: &mut KimiK3UnifiedParser, chunks: &[&str]) -> UnifiedParserOutput {
680
+ let mut output = UnifiedParserOutput::default();
681
+ for chunk in chunks {
682
+ output.append(parser.parse_chunk(chunk).unwrap());
683
+ }
684
+ output.append(parser.finish().unwrap());
685
+ output
686
+ }
687
+
688
+ /// Split `text` into small chunks to stress marker-split handling.
689
+ fn char_chunks(text: &str, size: usize) -> Vec<String> {
690
+ let chars: Vec<char> = text.chars().collect();
691
+ chars.chunks(size).map(|chunk| chunk.iter().collect()).collect()
692
+ }
693
+
694
+ fn arg(key: &str, arg_type: &str, value: &str) -> String {
695
+ format!(
696
+ "{OPEN}argument key=\"{key}\" type=\"{arg_type}\"{SEP}{value}<|close|>argument{SEP}"
697
+ )
698
+ }
699
+
700
+ fn call(attrs: &str, body: &str) -> String {
701
+ format!("{OPEN}call {attrs}{SEP}{body}<|close|>call{SEP}")
702
+ }
703
+
704
+ fn thinking_output(reasoning: &str, response: &str, tools_body: &str) -> String {
705
+ let mut output = format!("{THINK_OPEN}{reasoning}{THINK_CLOSE}");
706
+ output.push_str(&format!("{RESPONSE_OPEN}{response}{RESPONSE_CLOSE}"));
707
+ if !tools_body.is_empty() {
708
+ output.push_str(&format!("{TOOLS_OPEN}{tools_body}{TOOLS_CLOSE}"));
709
+ }
710
+ output.push_str("<|close|>message<|sep|>");
711
+ output
712
+ }
713
+
714
+ fn first_call(output: &UnifiedParserOutput) -> ToolCallDelta {
715
+ output.calls().first().expect("expected one tool call").clone()
716
+ }
717
+
718
+ #[test]
719
+ fn kimi_k3_create_requires_structural_tokens() {
720
+ let error = match KimiK3UnifiedParser::new(&[], Arc::new(TestTokenizer::new())) {
721
+ Ok(_) => panic!("expected missing token error"),
722
+ Err(error) => error,
723
+ };
724
+
725
+ assert!(matches!(
726
+ error,
727
+ UnifiedParserError::MissingToken { token } if token == OPEN
728
+ ));
729
+ }
730
+
731
+ #[test]
732
+ fn kimi_k3_parses_reasoning_response_and_typed_tool_call() {
733
+ let body = [
734
+ arg("city", "string", "Hangzhou"),
735
+ arg("days", "number", "1.5"),
736
+ arg("detailed", "boolean", "true"),
737
+ arg("filters", "object", r#"{"kind":"rain"}"#),
738
+ arg("hours", "array", "[8,20]"),
739
+ ]
740
+ .concat();
741
+ let text = thinking_output(
742
+ "Need the weather tool.",
743
+ "I'll check.",
744
+ &call("tool=\"get_weather\" index=\"1\"", &body),
745
+ );
746
+
747
+ let mut parser = test_parser();
748
+ let output = parser.parse_complete(&text).unwrap();
749
+
750
+ assert_eq!(output.reasoning_text(), "Need the weather tool.");
751
+ assert_eq!(output.normal_text(), "I'll check.");
752
+ let call = first_call(&output);
753
+ assert_eq!(call.tool_index, 0);
754
+ assert_eq!(call.name.as_deref(), Some("get_weather"));
755
+ assert_eq!(
756
+ serde_json::from_str::<Value>(&call.arguments).unwrap(),
757
+ json!({
758
+ "city": "Hangzhou",
759
+ "days": 1.5,
760
+ "detailed": true,
761
+ "filters": { "kind": "rain" },
762
+ "hours": [8, 20],
763
+ })
764
+ );
765
+ assert_eq!(parser.tool_call_id(0), Some("get_weather:0"));
766
+ }
767
+
768
+ #[test]
769
+ fn kimi_k3_arguments_preserve_order_and_number_formatting() {
770
+ let body = [
771
+ arg("y", "number", "1.0"),
772
+ arg("x", "number", "2"),
773
+ arg("items", "array", r#"["left","right"]"#),
774
+ ]
775
+ .concat();
776
+ let text = thinking_output("t", "", &call("tool=\"add\" index=\"1\"", &body));
777
+
778
+ let mut parser = test_parser();
779
+ let output = parser.parse_complete(&text).unwrap();
780
+
781
+ assert_eq!(
782
+ first_call(&output).arguments,
783
+ r#"{"y":1.0,"x":2,"items":["left","right"]}"#
784
+ );
785
+ }
786
+
787
+ #[test]
788
+ fn kimi_k3_streaming_splits_markers_across_chunks() {
789
+ let text = thinking_output(
790
+ "step by step",
791
+ "the answer",
792
+ &call("tool=\"calc\" index=\"1\"", &arg("x", "number", "42")),
793
+ );
794
+
795
+ for size in [1, 3, 7] {
796
+ let chunks = char_chunks(&text, size);
797
+ let chunk_refs: Vec<&str> = chunks.iter().map(String::as_str).collect();
798
+ let output = collect_stream(&mut test_parser(), &chunk_refs);
799
+
800
+ assert_eq!(output.reasoning_text(), "step by step", "chunk size {size}");
801
+ assert_eq!(output.normal_text(), "the answer", "chunk size {size}");
802
+ assert_eq!(first_call(&output).name.as_deref(), Some("calc"));
803
+ assert_eq!(first_call(&output).arguments, r#"{"x":42}"#);
804
+ }
805
+ }
806
+
807
+ #[test]
808
+ fn kimi_k3_streaming_emits_text_incrementally() {
809
+ let mut parser = test_parser();
810
+ let prompt = tokenizer().encode("<|open|>response<|sep|>", false).unwrap();
811
+ parser.initialize(&prompt).unwrap();
812
+
813
+ let first = parser.parse_chunk("Hel").unwrap();
814
+ assert_eq!(first.normal_text(), "Hel");
815
+
816
+ let second = parser.parse_chunk("lo<|close|>resp").unwrap();
817
+ assert_eq!(second.normal_text(), "lo");
818
+
819
+ let mut output = parser.parse_chunk("onse<|sep|>").unwrap();
820
+ output.append(parser.finish().unwrap());
821
+ assert_eq!(output.normal_text(), "");
822
+ }
823
+
824
+ #[test]
825
+ fn kimi_k3_initialize_think_prefill_starts_in_reasoning() {
826
+ let mut parser = test_parser();
827
+ let prompt = tokenizer()
828
+ .encode(
829
+ "<|open|>message role=\"assistant\"<|sep|><|open|>think<|sep|>",
830
+ false,
831
+ )
832
+ .unwrap();
833
+ parser.initialize(&prompt).unwrap();
834
+
835
+ let output = parser
836
+ .parse_complete(&format!(
837
+ "reasoning{THINK_CLOSE}{RESPONSE_OPEN}answer{RESPONSE_CLOSE}<|close|>message{SEP}"
838
+ ))
839
+ .unwrap();
840
+
841
+ assert_eq!(output.reasoning_text(), "reasoning");
842
+ assert_eq!(output.normal_text(), "answer");
843
+ }
844
+
845
+ #[test]
846
+ fn kimi_k3_initialize_response_prefill_starts_in_response() {
847
+ let mut parser = test_parser();
848
+ let prompt = tokenizer()
849
+ .encode(
850
+ "<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>",
851
+ false,
852
+ )
853
+ .unwrap();
854
+ parser.initialize(&prompt).unwrap();
855
+
856
+ let output = parser
857
+ .parse_complete(&format!("answer{RESPONSE_CLOSE}<|close|>message{SEP}"))
858
+ .unwrap();
859
+
860
+ assert_eq!(output.normal_text(), "answer");
861
+ assert!(output.reasoning_text().is_empty());
862
+ }
863
+
864
+ #[test]
865
+ fn kimi_k3_initialize_message_open_prefill_starts_idle() {
866
+ let mut parser = test_parser();
867
+ let prompt =
868
+ tokenizer().encode("<|open|>message role=\"assistant\"<|sep|>", false).unwrap();
869
+ parser.initialize(&prompt).unwrap();
870
+
871
+ let output = parser
872
+ .parse_complete(&format!("{THINK_OPEN}reason{THINK_CLOSE}{RESPONSE_OPEN}hi"))
873
+ .unwrap();
874
+
875
+ assert_eq!(output.reasoning_text(), "reason");
876
+ assert_eq!(output.normal_text(), "hi");
877
+ }
878
+
879
+ #[test]
880
+ fn kimi_k3_plain_text_falls_through_as_text() {
881
+ let output = collect_stream(&mut test_parser(), &["plain ", "answer"]);
882
+
883
+ assert_eq!(output.normal_text(), "plain answer");
884
+ assert!(output.reasoning_text().is_empty());
885
+ assert!(output.calls().is_empty());
886
+ }
887
+
888
+ #[test]
889
+ fn kimi_k3_tool_call_waits_for_close_marker() {
890
+ let mut parser = test_parser();
891
+ let mut output = UnifiedParserOutput::default();
892
+
893
+ let argument = arg("x", "number", "1");
894
+ for chunk in [
895
+ TOOLS_OPEN,
896
+ "<|open|>call tool=\"calc\" index=\"1\"<|sep|>",
897
+ argument.as_str(),
898
+ ] {
899
+ output.append(parser.parse_chunk(chunk).unwrap());
900
+ assert!(output.calls().is_empty());
901
+ }
902
+
903
+ output.append(parser.parse_chunk("<|close|>call<|sep|>").unwrap());
904
+
905
+ assert_eq!(first_call(&output).name.as_deref(), Some("calc"));
906
+ assert_eq!(first_call(&output).arguments, r#"{"x":1}"#);
907
+ }
908
+
909
+ #[test]
910
+ fn kimi_k3_parses_multiple_tool_calls() {
911
+ let tools_body = format!(
912
+ "{}{}",
913
+ call(
914
+ "tool=\"get_weather\" index=\"1\"",
915
+ &arg("city", "string", "SF")
916
+ ),
917
+ call("tool=\"get_time\" index=\"2\"", ""),
918
+ );
919
+ let text = thinking_output("t", "r", &tools_body);
920
+
921
+ let mut parser = test_parser();
922
+ let output = parser.parse_complete(&text).unwrap();
923
+
924
+ let calls = output.calls();
925
+ assert_eq!(calls.len(), 2);
926
+ assert_eq!(calls[0].tool_index, 0);
927
+ assert_eq!(calls[0].name.as_deref(), Some("get_weather"));
928
+ assert_eq!(calls[0].arguments, r#"{"city":"SF"}"#);
929
+ assert_eq!(calls[1].tool_index, 1);
930
+ assert_eq!(calls[1].name.as_deref(), Some("get_time"));
931
+ assert_eq!(calls[1].arguments, "{}");
932
+ assert_eq!(parser.tool_call_id(0), Some("get_weather:0"));
933
+ assert_eq!(parser.tool_call_id(1), Some("get_time:1"));
934
+ }
935
+
936
+ #[test]
937
+ fn kimi_k3_json_block_arguments_pass_through_raw() {
938
+ // Spacing and key order must survive unmodified: raw `json` blocks are
939
+ // not validated or normalized.
940
+ let raw = r#"{"b": 1, "a": [2 , 3]}"#;
941
+ let body = format!("{OPEN}json type=\"object\"{SEP}{raw}<|close|>json{SEP}");
942
+ let text = thinking_output("t", "", &call("tool=\"run\" index=\"1\"", &body));
943
+
944
+ let mut parser = test_parser();
945
+ let output = parser.parse_complete(&text).unwrap();
946
+
947
+ assert_eq!(first_call(&output).arguments, raw);
948
+ }
949
+
950
+ #[test]
951
+ fn kimi_k3_string_argument_passes_raw_text_through() {
952
+ let value = "line one\nline two {\"not\": \"json\"} & <tags>";
953
+ let text = thinking_output(
954
+ "t",
955
+ "",
956
+ &call(
957
+ "tool=\"write\" index=\"1\"",
958
+ &arg("content", "string", value),
959
+ ),
960
+ );
961
+
962
+ let mut parser = test_parser();
963
+ let output = parser.parse_complete(&text).unwrap();
964
+
965
+ assert_eq!(
966
+ serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
967
+ json!({ "content": value })
968
+ );
969
+ }
970
+
971
+ #[test]
972
+ fn kimi_k3_malformed_typed_argument_falls_back_to_raw_text() {
973
+ let text = thinking_output(
974
+ "t",
975
+ "",
976
+ &call(
977
+ "tool=\"calc\" index=\"1\"",
978
+ &arg("x", "number", "not a number"),
979
+ ),
980
+ );
981
+
982
+ let mut parser = test_parser();
983
+ let output = parser.parse_complete(&text).unwrap();
984
+
985
+ assert_eq!(first_call(&output).arguments, r#"{"x":"not a number"}"#);
986
+ }
987
+
988
+ #[test]
989
+ fn kimi_k3_attribute_values_are_unescaped() {
990
+ let text = thinking_output(
991
+ "t",
992
+ "",
993
+ &call(
994
+ "tool=\"a"b&c\" index=\"1\"",
995
+ &arg("key", "string", "value"),
996
+ ),
997
+ );
998
+
999
+ let mut parser = test_parser();
1000
+ let output = parser.parse_complete(&text).unwrap();
1001
+
1002
+ assert_eq!(first_call(&output).name.as_deref(), Some("a\"b&c"));
1003
+ }
1004
+
1005
+ #[test]
1006
+ fn kimi_k3_call_without_tool_name_is_dropped() {
1007
+ let tools_body = format!(
1008
+ "{}{}",
1009
+ call("index=\"1\"", &arg("x", "number", "1")),
1010
+ call("tool=\"real\" index=\"2\"", ""),
1011
+ );
1012
+ let text = thinking_output("t", "", &tools_body);
1013
+
1014
+ let mut parser = test_parser();
1015
+ let output = parser.parse_complete(&text).unwrap();
1016
+
1017
+ let calls = output.calls();
1018
+ assert_eq!(calls.len(), 1);
1019
+ assert_eq!(calls[0].tool_index, 0);
1020
+ assert_eq!(calls[0].name.as_deref(), Some("real"));
1021
+ assert_eq!(parser.tool_call_id(0), Some("real:1"));
1022
+ }
1023
+
1024
+ #[test]
1025
+ fn kimi_k3_tool_call_id_follows_index_attribute() {
1026
+ let tools_body = format!(
1027
+ "{}{}{}",
1028
+ call("tool=\"first\" index=\"3\"", ""),
1029
+ call("tool=\"second\"", ""),
1030
+ call("tool=\"third\" index=\"x\"", ""),
1031
+ );
1032
+ let text = thinking_output("t", "", &tools_body);
1033
+
1034
+ let mut parser = test_parser();
1035
+ parser.parse_complete(&text).unwrap();
1036
+
1037
+ assert_eq!(parser.tool_call_id(0), Some("first:2"));
1038
+ assert_eq!(parser.tool_call_id(1), Some("second"));
1039
+ assert_eq!(parser.tool_call_id(2), Some("third:x"));
1040
+ }
1041
+
1042
+ #[test]
1043
+ fn kimi_k3_ignores_output_after_message_close() {
1044
+ let mut parser = test_parser();
1045
+ let output = parser
1046
+ .parse_complete(&format!(
1047
+ "{RESPONSE_OPEN}answer{RESPONSE_CLOSE}<|close|>message{SEP}junk{END_OF_MSG}"
1048
+ ))
1049
+ .unwrap();
1050
+
1051
+ assert_eq!(output.normal_text(), "answer");
1052
+ }
1053
+
1054
+ #[test]
1055
+ fn kimi_k3_epilogue_noise_is_not_content() {
1056
+ let text = format!(
1057
+ "{RESPONSE_OPEN}answer{RESPONSE_CLOSE}\n{TOOLS_OPEN}{}{TOOLS_CLOSE}\n<|close|>message{SEP}",
1058
+ call("tool=\"calc\" index=\"1\"", ""),
1059
+ );
1060
+
1061
+ let mut parser = test_parser();
1062
+ let output = parser.parse_complete(&text).unwrap();
1063
+
1064
+ assert_eq!(output.normal_text(), "answer");
1065
+ assert_eq!(output.calls().len(), 1);
1066
+ }
1067
+
1068
+ #[test]
1069
+ fn kimi_k3_finish_flushes_unclosed_reasoning() {
1070
+ let mut parser = test_parser();
1071
+ let mut output = parser.parse_chunk(&format!("{THINK_OPEN}still thinking")).unwrap();
1072
+ output.append(parser.finish().unwrap());
1073
+
1074
+ assert_eq!(output.reasoning_text(), "still thinking");
1075
+ assert!(output.normal_text().is_empty());
1076
+ }
1077
+
1078
+ #[test]
1079
+ fn kimi_k3_finish_flushes_partial_marker_as_text() {
1080
+ let mut parser = test_parser();
1081
+ let mut output = parser.parse_chunk("answer<|clo").unwrap();
1082
+ output.append(parser.finish().unwrap());
1083
+
1084
+ assert_eq!(output.normal_text(), "answer<|clo");
1085
+ }
1086
+
1087
+ #[test]
1088
+ fn kimi_k3_finish_fails_mid_tool_call() {
1089
+ let mut parser = test_parser();
1090
+ parser
1091
+ .parse_chunk(&format!(
1092
+ "{TOOLS_OPEN}<|open|>call tool=\"calc\" index=\"1\"<|sep|>{}",
1093
+ arg("x", "number", "1")
1094
+ ))
1095
+ .unwrap();
1096
+
1097
+ let error = parser.finish().unwrap_err();
1098
+
1099
+ assert!(error.to_report_string().contains("incomplete Kimi K3 tool call"));
1100
+ }
1101
+
1102
+ #[test]
1103
+ fn kimi_k3_finish_after_truncated_tools_keeps_complete_calls() {
1104
+ let mut parser = test_parser();
1105
+ let mut output = parser
1106
+ .parse_chunk(&format!(
1107
+ "{TOOLS_OPEN}{}",
1108
+ call("tool=\"calc\" index=\"1\"", &arg("x", "number", "1"))
1109
+ ))
1110
+ .unwrap();
1111
+ output.append(parser.finish().unwrap());
1112
+
1113
+ assert_eq!(first_call(&output).name.as_deref(), Some("calc"));
1114
+ }
1115
+
1116
+ #[test]
1117
+ fn kimi_k3_malformed_call_attributes_fail_fast() {
1118
+ let mut parser = test_parser();
1119
+ let error = parser
1120
+ .parse_chunk(&format!("{TOOLS_OPEN}<|open|>call garbage attrs<|sep|>"))
1121
+ .unwrap_err();
1122
+
1123
+ assert!(error.to_report_string().contains("XTML tag attributes"));
1124
+ }
1125
+
1126
+ #[test]
1127
+ fn kimi_k3_empty_response_channel_emits_nothing() {
1128
+ let text = thinking_output("t", "", &call("tool=\"calc\" index=\"1\"", ""));
1129
+
1130
+ let mut parser = test_parser();
1131
+ let output = parser.parse_complete(&text).unwrap();
1132
+
1133
+ assert!(output.normal_text().is_empty());
1134
+ assert_eq!(output.reasoning_text(), "t");
1135
+ assert_eq!(output.calls().len(), 1);
1136
+ }
1137
+
1138
+ #[test]
1139
+ fn kimi_k3_reset_returns_buffered_text() {
1140
+ let mut parser = test_parser();
1141
+ let prompt = tokenizer().encode("<|open|>response<|sep|>", false).unwrap();
1142
+ parser.initialize(&prompt).unwrap();
1143
+ parser.parse_chunk("answer<|close|>resp").unwrap();
1144
+
1145
+ let raw = parser.reset();
1146
+
1147
+ assert_eq!(raw, "<|close|>resp");
1148
+ }
1149
+}
rust/src/parser/src/unified/kimi_k3/structural_tag.rs
new
+503
@@ -0,0 +1,503 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Structural-tag grammar for Kimi K3 XTML tool calls.
5
+
6
+use serde_json::{Map, Value};
7
+use xgrammar_structural_tag::Result;
8
+use xgrammar_structural_tag::builders::{
9
+ StructuralTagBuilder, StructuralTagContext, StructuralTagOptions,
10
+};
11
+use xgrammar_structural_tag::format::{Format, JsonSchemaFormat, StructuralTag, TagFormat};
12
+use xgrammar_structural_tag::tool::{BuilderToolChoice, FunctionToolParam, function_parameters};
13
+
14
+use super::{
15
+ ARG_CLOSE, CALL_CLOSE, END_OF_MSG, JSON_CLOSE, JSON_OPEN, MESSAGE_CLOSE, OPEN, RESPONSE_CLOSE,
16
+ RESPONSE_OPEN, SEP, THINK_CLOSE, TOOLS_CLOSE, TOOLS_OPEN,
17
+};
18
+
19
+pub(super) static KIMI_K3_STRUCTURAL_TAG_BUILDER: KimiK3StructuralTagBuilder =
20
+ KimiK3StructuralTagBuilder;
21
+
22
+const XTML_TYPES: &[&str] = &["string", "number", "boolean", "null", "object", "array"];
23
+
24
+/// Kimi K3 XTML structural-tag builder.
25
+#[derive(Debug, Clone, Copy, Default)]
26
+pub struct KimiK3StructuralTagBuilder;
27
+
28
+impl StructuralTagBuilder for KimiK3StructuralTagBuilder {
29
+ fn build(&self, ctx: StructuralTagContext<'_>) -> Result<StructuralTag> {
30
+ let mut elements = response_prefix(ctx.options.reasoning);
31
+
32
+ let tools = match ctx.tool_choice {
33
+ // Serving lowering filters empty tools before calling the builder,
34
+ // while direct `build_structural_tag(..., auto, ...)` calls do not.
35
+ BuilderToolChoice::Auto if ctx.function_tools.is_empty() => None,
36
+ BuilderToolChoice::Auto => Some(Format::optional(tools_channel(
37
+ ctx.function_tools,
38
+ ctx.options,
39
+ ))),
40
+ BuilderToolChoice::Forced | BuilderToolChoice::Required => {
41
+ Some(tools_channel(ctx.function_tools, ctx.options))
42
+ }
43
+ };
44
+ if let Some(tools) = tools {
45
+ elements.push(tools);
46
+ }
47
+ elements.push(Format::optional(Format::const_string(MESSAGE_CLOSE)));
48
+
49
+ Ok(StructuralTag::new(Format::sequence(elements)))
50
+ }
51
+}
52
+
53
+fn response_prefix(reasoning: bool) -> Vec<Format> {
54
+ let mut elements = Vec::new();
55
+ if reasoning {
56
+ elements.push(Format::tag(
57
+ "",
58
+ Format::any_text_excluding(&[THINK_CLOSE, END_OF_MSG]),
59
+ THINK_CLOSE,
60
+ ));
61
+ elements.push(Format::const_string(RESPONSE_OPEN));
62
+ } else {
63
+ elements.push(Format::optional(Format::const_string(RESPONSE_OPEN)));
64
+ }
65
+ elements.push(Format::tag(
66
+ "",
67
+ Format::any_text_excluding(&[RESPONSE_CLOSE, TOOLS_OPEN, MESSAGE_CLOSE, END_OF_MSG]),
68
+ RESPONSE_CLOSE,
69
+ ));
70
+ elements
71
+}
72
+
73
+fn tools_channel(tools: &[FunctionToolParam], options: StructuralTagOptions) -> Format {
74
+ let calls = tools.iter().map(|tool| call_tag(tool, options)).collect();
75
+ Format::tag(
76
+ TOOLS_OPEN,
77
+ Format::tags_with_separator(calls, "", true, false),
78
+ TOOLS_CLOSE,
79
+ )
80
+}
81
+
82
+fn call_tag(tool: &FunctionToolParam, options: StructuralTagOptions) -> TagFormat {
83
+ let parameters = function_parameters(&tool.function);
84
+ let call_body = Format::or(vec![
85
+ typed_arguments(¶meters, options),
86
+ raw_json_arguments(¶meters, options),
87
+ ]);
88
+
89
+ TagFormat::new(
90
+ format!(
91
+ "{OPEN}call tool=\"{}\" index=\"",
92
+ escape_attr_value(&tool.function.name)
93
+ ),
94
+ Format::sequence(vec![
95
+ Format::regex("[1-9][0-9]*"),
96
+ Format::const_string(format!("\"{SEP}")),
97
+ call_body,
98
+ ]),
99
+ CALL_CLOSE,
100
+ )
101
+}
102
+
103
+fn typed_arguments(parameters: &Value, options: StructuralTagOptions) -> Format {
104
+ let Some(schema) = parameters.as_object() else {
105
+ return if parameters == &Value::Bool(false) {
106
+ Format::const_string("")
107
+ } else {
108
+ Format::star(permissive_argument())
109
+ };
110
+ };
111
+ let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
112
+ return Format::star(permissive_argument());
113
+ };
114
+ if properties.is_empty() {
115
+ return Format::star(permissive_argument());
116
+ }
117
+
118
+ let root_defs = root_definitions(schema);
119
+ let arguments = properties
120
+ .iter()
121
+ .flat_map(|(key, schema)| argument_tags(key, schema, &root_defs, options))
122
+ .map(Format::Tag)
123
+ .collect::<Vec<_>>();
124
+ let arguments = match arguments.as_slice() {
125
+ [argument] => argument.clone(),
126
+ _ => Format::or(arguments),
127
+ };
128
+ // Keep typed arguments order-agnostic and non-unique, but do not allow an
129
+ // empty call when the root schema declares required properties.
130
+ if schema
131
+ .get("required")
132
+ .and_then(Value::as_array)
133
+ .is_some_and(|required| !required.is_empty())
134
+ {
135
+ Format::plus(arguments)
136
+ } else {
137
+ Format::star(arguments)
138
+ }
139
+}
140
+
141
+fn argument_tags(
142
+ key: &str,
143
+ schema: &Value,
144
+ root_defs: &Map<String, Value>,
145
+ options: StructuralTagOptions,
146
+) -> Vec<TagFormat> {
147
+ let types = schema_types(schema);
148
+ types
149
+ .into_iter()
150
+ .map(|xtml_type| {
151
+ let content = if xtml_type == "string" {
152
+ string_argument_content(schema)
153
+ } else {
154
+ json_schema(
155
+ attach_root_definitions(&narrow_schema_type(schema, xtml_type), root_defs),
156
+ options,
157
+ )
158
+ };
159
+ TagFormat::new(
160
+ format!(
161
+ "{OPEN}argument key=\"{}\" type=\"{xtml_type}\"{SEP}",
162
+ escape_attr_value(key)
163
+ ),
164
+ content,
165
+ ARG_CLOSE,
166
+ )
167
+ })
168
+ .collect()
169
+}
170
+
171
+fn schema_types(schema: &Value) -> Vec<&'static str> {
172
+ let Some(schema) = schema.as_object() else {
173
+ return XTML_TYPES.to_vec();
174
+ };
175
+ let mut types = Vec::new();
176
+ match schema.get("type") {
177
+ Some(Value::String(value)) => push_schema_type(&mut types, value),
178
+ Some(Value::Array(values)) => {
179
+ for value in values.iter().filter_map(Value::as_str) {
180
+ push_schema_type(&mut types, value);
181
+ }
182
+ }
183
+ _ => {}
184
+ }
185
+ if types.is_empty()
186
+ && let Some(value) = schema.get("const")
187
+ {
188
+ push_value_type(&mut types, value);
189
+ }
190
+ if types.is_empty()
191
+ && let Some(values) = schema.get("enum").and_then(Value::as_array)
192
+ {
193
+ for value in values {
194
+ push_value_type(&mut types, value);
195
+ }
196
+ }
197
+ if types.is_empty() {
198
+ XTML_TYPES.to_vec()
199
+ } else {
200
+ types
201
+ }
202
+}
203
+
204
+fn push_value_type(types: &mut Vec<&'static str>, value: &Value) {
205
+ let xtml_type = match value {
206
+ Value::String(_) => "string",
207
+ Value::Number(_) => "number",
208
+ Value::Bool(_) => "boolean",
209
+ Value::Null => "null",
210
+ Value::Object(_) => "object",
211
+ Value::Array(_) => "array",
212
+ };
213
+ if !types.contains(&xtml_type) {
214
+ types.push(xtml_type);
215
+ }
216
+}
217
+
218
+fn narrow_schema_type(schema: &Value, xtml_type: &str) -> Value {
219
+ let Some(mut schema) = schema.as_object().cloned() else {
220
+ return schema.clone();
221
+ };
222
+ let json_type = if xtml_type == "number" && explicitly_integer_only(&schema) {
223
+ "integer"
224
+ } else {
225
+ xtml_type
226
+ };
227
+ schema.insert("type".to_string(), Value::String(json_type.to_string()));
228
+ Value::Object(schema)
229
+}
230
+
231
+fn explicitly_integer_only(schema: &Map<String, Value>) -> bool {
232
+ match schema.get("type") {
233
+ Some(Value::String(value)) => value == "integer",
234
+ Some(Value::Array(values)) => {
235
+ let values = values.iter().filter_map(Value::as_str).collect::<Vec<_>>();
236
+ values.contains(&"integer") && !values.contains(&"number")
237
+ }
238
+ _ => false,
239
+ }
240
+}
241
+
242
+fn push_schema_type(types: &mut Vec<&'static str>, json_type: &str) {
243
+ let xtml_type = match json_type {
244
+ "string" => Some("string"),
245
+ "integer" | "number" => Some("number"),
246
+ "boolean" => Some("boolean"),
247
+ "null" => Some("null"),
248
+ "object" => Some("object"),
249
+ "array" => Some("array"),
250
+ _ => None,
251
+ };
252
+ if let Some(xtml_type) = xtml_type
253
+ && !types.contains(&xtml_type)
254
+ {
255
+ types.push(xtml_type);
256
+ }
257
+}
258
+
259
+fn string_argument_content(schema: &Value) -> Format {
260
+ let Some(schema) = schema.as_object() else {
261
+ return Format::any_text_excluding(&[ARG_CLOSE, CALL_CLOSE]);
262
+ };
263
+ let values = schema
264
+ .get("enum")
265
+ .and_then(Value::as_array)
266
+ .cloned()
267
+ .or_else(|| schema.get("const").cloned().map(|value| vec![value]));
268
+ let Some(values) = values else {
269
+ return Format::any_text_excluding(&[ARG_CLOSE, CALL_CLOSE]);
270
+ };
271
+ if values.is_empty()
272
+ || values.len() > 256
273
+ || values
274
+ .iter()
275
+ .any(|value| value.as_str().is_none_or(|value| value.contains("<|")))
276
+ {
277
+ return Format::any_text_excluding(&[ARG_CLOSE, CALL_CLOSE]);
278
+ }
279
+ let values = values.iter().filter_map(Value::as_str).collect::<Vec<_>>();
280
+ match values.as_slice() {
281
+ [value] => Format::const_string(*value),
282
+ _ => Format::or(values.into_iter().map(Format::const_string).collect()),
283
+ }
284
+}
285
+
286
+fn raw_json_arguments(parameters: &Value, options: StructuralTagOptions) -> Format {
287
+ Format::tag(
288
+ format!("{JSON_OPEN} type=\"object\"{SEP}"),
289
+ json_schema(parameters.clone(), options),
290
+ JSON_CLOSE,
291
+ )
292
+}
293
+
294
+fn permissive_argument() -> Format {
295
+ let key = Format::regex(r#"(?:[^<\"&]|&(?:amp|quot);|<[^|])*"#);
296
+ let alternatives = XTML_TYPES
297
+ .iter()
298
+ .map(|xtml_type| {
299
+ Format::sequence(vec![
300
+ key.clone(),
301
+ Format::const_string(format!("\" type=\"{xtml_type}\"{SEP}")),
302
+ if *xtml_type == "string" {
303
+ Format::any_text_excluding(&[ARG_CLOSE, CALL_CLOSE])
304
+ } else {
305
+ Format::json_schema(Value::Bool(true))
306
+ },
307
+ ])
308
+ })
309
+ .collect();
310
+ Format::tag(
311
+ format!("{OPEN}argument key=\""),
312
+ Format::or(alternatives),
313
+ ARG_CLOSE,
314
+ )
315
+}
316
+
317
+fn json_schema(schema: Value, options: StructuralTagOptions) -> Format {
318
+ Format::JsonSchema(
319
+ JsonSchemaFormat::new(schema)
320
+ .with_any_order(options.any_order)
321
+ .with_max_whitespace_cnt(options.max_whitespace_cnt),
322
+ )
323
+}
324
+
325
+fn root_definitions(schema: &Map<String, Value>) -> Map<String, Value> {
326
+ ["$defs", "definitions"]
327
+ .into_iter()
328
+ .filter_map(|key| schema.get(key).map(|value| (key.to_string(), value.clone())))
329
+ .collect()
330
+}
331
+
332
+fn attach_root_definitions(schema: &Value, root_defs: &Map<String, Value>) -> Value {
333
+ let Some(mut schema) = schema.as_object().cloned() else {
334
+ return schema.clone();
335
+ };
336
+ for (key, value) in root_defs {
337
+ schema.entry(key.clone()).or_insert_with(|| value.clone());
338
+ }
339
+ Value::Object(schema)
340
+}
341
+
342
+fn escape_attr_value(value: &str) -> String {
343
+ value.replace('&', "&").replace('"', """)
344
+}
345
+
346
+#[cfg(test)]
347
+mod tests {
348
+ use expect_test::expect;
349
+ use serde_json::json;
350
+ use xgrammar_structural_tag::builders::StructuralTagOptions;
351
+ use xgrammar_structural_tag::{
352
+ FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
353
+ };
354
+
355
+ use super::KimiK3StructuralTagBuilder;
356
+
357
+ fn tool(name: &str, parameters: serde_json::Value) -> ToolParam {
358
+ ToolParam::Function(FunctionToolParam::new(
359
+ FunctionDefinition::new(name).with_parameters(parameters),
360
+ ))
361
+ }
362
+
363
+ #[test]
364
+ fn required_structural_tag_matches_xtml_channels() {
365
+ let tools = vec![tool(
366
+ "get_weather",
367
+ json!({
368
+ "$defs": {
369
+ "place": { "type": "object", "properties": { "city": { "type": "string" } } }
370
+ },
371
+ "type": "object",
372
+ "properties": {
373
+ "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] },
374
+ "place": { "$ref": "#/$defs/place", "type": "object" }
375
+ },
376
+ "required": ["place"]
377
+ }),
378
+ )];
379
+ let tag = build_structural_tag(
380
+ KimiK3StructuralTagBuilder,
381
+ &tools,
382
+ ToolChoice::required(),
383
+ StructuralTagOptions::default().with_reasoning(false),
384
+ )
385
+ .unwrap();
386
+
387
+ expect![[r##"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"optional","content":{"type":"const_string","value":"<|open|>response<|sep|>"}},{"type":"tag","begin":"","content":{"type":"any_text","excludes":["<|close|>response<|sep|>","<|open|>tools<|sep|>","<|close|>message<|sep|>","<|end_of_msg|>"]},"end":"<|close|>response<|sep|>"},{"type":"tag","begin":"<|open|>tools<|sep|>","content":{"type":"tags_with_separator","tags":[{"begin":"<|open|>call tool=\"get_weather\" index=\"","content":{"type":"sequence","elements":[{"type":"regex","pattern":"[1-9][0-9]*"},{"type":"const_string","value":"\"<|sep|>"},{"type":"or","elements":[{"type":"plus","content":{"type":"or","elements":[{"type":"tag","begin":"<|open|>argument key=\"unit\" type=\"string\"<|sep|>","content":{"type":"or","elements":[{"type":"const_string","value":"celsius"},{"type":"const_string","value":"fahrenheit"}]},"end":"<|close|>argument<|sep|>"},{"type":"tag","begin":"<|open|>argument key=\"place\" type=\"object\"<|sep|>","content":{"type":"json_schema","json_schema":{"$ref":"#/$defs/place","type":"object","$defs":{"place":{"type":"object","properties":{"city":{"type":"string"}}}}},"style":"json","any_order":false,"max_whitespace_cnt":null},"end":"<|close|>argument<|sep|>"}]}},{"type":"tag","begin":"<|open|>json type=\"object\"<|sep|>","content":{"type":"json_schema","json_schema":{"$defs":{"place":{"type":"object","properties":{"city":{"type":"string"}}}},"type":"object","properties":{"unit":{"type":"string","enum":["celsius","fahrenheit"]},"place":{"$ref":"#/$defs/place","type":"object"}},"required":["place"]},"style":"json","any_order":false,"max_whitespace_cnt":null},"end":"<|close|>json<|sep|>"}]}]},"end":"<|close|>call<|sep|>"}],"separator":"","at_least_one":true,"stop_after_first":false},"end":"<|close|>tools<|sep|>"},{"type":"optional","content":{"type":"const_string","value":"<|close|>message<|sep|>"}}]}}"##]].assert_eq(&tag.to_json_string().unwrap());
388
+ }
389
+
390
+ #[test]
391
+ fn typed_arguments_require_one_tag_only_for_nonempty_required() {
392
+ let required = super::typed_arguments(
393
+ &json!({
394
+ "type": "object",
395
+ "properties": { "query": { "type": "string" } },
396
+ "required": ["query"]
397
+ }),
398
+ StructuralTagOptions::default(),
399
+ );
400
+ let optional = super::typed_arguments(
401
+ &json!({
402
+ "type": "object",
403
+ "properties": { "query": { "type": "string" } }
404
+ }),
405
+ StructuralTagOptions::default(),
406
+ );
407
+ let empty_required = super::typed_arguments(
408
+ &json!({
409
+ "type": "object",
410
+ "properties": { "query": { "type": "string" } },
411
+ "required": []
412
+ }),
413
+ StructuralTagOptions::default(),
414
+ );
415
+
416
+ assert_eq!(serde_json::to_value(required).unwrap()["type"], "plus");
417
+ assert_eq!(serde_json::to_value(optional).unwrap()["type"], "star");
418
+ assert_eq!(
419
+ serde_json::to_value(empty_required).unwrap()["type"],
420
+ "star"
421
+ );
422
+ }
423
+
424
+ #[test]
425
+ fn reasoning_grammar_starts_inside_prefilled_think_channel() {
426
+ let tools = vec![tool("ping", json!({ "type": "object", "properties": {} }))];
427
+ let tag = build_structural_tag(
428
+ KimiK3StructuralTagBuilder,
429
+ &tools,
430
+ ToolChoice::auto(),
431
+ StructuralTagOptions::default().with_reasoning(true),
432
+ )
433
+ .unwrap();
434
+ let value = serde_json::to_value(tag).unwrap();
435
+
436
+ assert_eq!(
437
+ value["format"]["elements"][0]["end"],
438
+ "<|close|>think<|sep|>"
439
+ );
440
+ assert_eq!(
441
+ value["format"]["elements"][1]["value"],
442
+ "<|open|>response<|sep|>"
443
+ );
444
+ assert_eq!(value["format"]["elements"][3]["type"], "optional");
445
+ }
446
+
447
+ #[test]
448
+ fn forced_choice_keeps_only_the_named_tool() {
449
+ let tools = vec![
450
+ tool("search", json!({ "type": "object" })),
451
+ tool("lookup", json!({ "type": "object" })),
452
+ ];
453
+ let tag = build_structural_tag(
454
+ KimiK3StructuralTagBuilder,
455
+ &tools,
456
+ ToolChoice::function("lookup"),
457
+ StructuralTagOptions::default().with_reasoning(false),
458
+ )
459
+ .unwrap()
460
+ .to_json_string()
461
+ .unwrap();
462
+
463
+ assert!(tag.contains("lookup"));
464
+ assert!(!tag.contains("search"));
465
+ }
466
+
467
+ #[test]
468
+ fn union_argument_content_matches_its_xtml_type() {
469
+ let tools = vec![tool(
470
+ "set_count",
471
+ json!({
472
+ "type": "object",
473
+ "properties": {
474
+ "count": { "type": ["integer", "null"] }
475
+ }
476
+ }),
477
+ )];
478
+ let tag = build_structural_tag(
479
+ KimiK3StructuralTagBuilder,
480
+ &tools,
481
+ ToolChoice::required(),
482
+ StructuralTagOptions::default(),
483
+ )
484
+ .unwrap()
485
+ .to_json_string()
486
+ .unwrap();
487
+
488
+ assert!(tag.contains(r#"type=\"number\""#));
489
+ assert!(tag.contains(r#""json_schema":{"type":"integer"}"#), "{tag}");
490
+ assert!(tag.contains(r#"type=\"null\""#));
491
+ assert!(tag.contains(r#""json_schema":{"type":"null"}"#), "{tag}");
492
+ }
493
+
494
+ #[test]
495
+ fn unsafe_string_enum_falls_back_as_a_whole() {
496
+ let format = super::string_argument_content(&json!({
497
+ "type": "string",
498
+ "enum": ["safe", "<|unsafe"]
499
+ }));
500
+
501
+ assert_eq!(serde_json::to_value(format).unwrap()["type"], "any_text");
502
+ }
503
+}
rust/src/parser/src/unified/mod.rs
+2
@@ -6,10 +6,12 @@
6
mod combined;
7
mod gemma4;
8
mod inkling;
9
+mod kimi_k3;
10
11
pub use combined::CombinedParser;
12
pub use gemma4::Gemma4UnifiedParser;
13
pub use inkling::InklingUnifiedParser;
14
+pub use kimi_k3::{KimiK3StructuralTagBuilder, KimiK3UnifiedParser};
15
use thiserror::Error;
16
use thiserror_ext::Macro;
17
use vllm_tokenizer::DynTokenizer;
rust/src/server/src/routes/openai/chat_completions/convert.rs
+70
-3
@@ -89,6 +89,15 @@ pub(super) fn prepare_chat_request(
89
)?;
90
91
let template_kwargs = request.chat_template_kwargs.unwrap_or_default();
92
+ let response_format =
93
+ request.response_format.as_ref().map(serde_json::to_value).transpose().map_err(
94
+ |error| {
95
+ ApiError::invalid_request(
96
+ format!("failed to serialize response_format: {error}"),
97
+ Some("response_format"),
98
+ )
99
+ },
100
+ )?;
101
102
let include_usage = (request.stream_options.as_ref())
103
.and_then(|options| options.include_usage)
@@ -150,6 +159,7 @@ pub(super) fn prepare_chat_request(
159
generation_prompt_mode,
160
chat_template: request.chat_template,
161
reasoning_effort: request.reasoning_effort,
162
+ response_format,
163
template_kwargs,
164
},
165
tools: convert_tools(request.tools)?,
@@ -400,6 +410,7 @@ fn convert_tool_choice(tool_choice: Option<&ToolChoice>) -> Result<ChatToolChoic
410
#[cfg(test)]
411
mod tests {
412
use std::collections::HashMap;
413
+ use std::sync::Arc;
414
415
use axum::http::HeaderMap;
416
use expect_test::expect;
@@ -407,16 +418,18 @@ mod tests {
418
use serde_json::json;
419
use vllm_chat::{
420
AssistantContentBlock, AssistantToolCall, ChatContentPart, ChatMessage as VllmChatMessage,
410
- ChatTool as VllmChatTool, ChatToolChoice, GenerationPromptMode,
411
- SamplingParams as VllmSamplingParams,
421
+ ChatRenderer, ChatTool as VllmChatTool, ChatToolChoice, GenerationPromptMode,
422
+ KimiK3ChatRenderer, SamplingParams as VllmSamplingParams,
423
};
413
- use vllm_text::output::TextDecodeOptions;
424
+ use vllm_text::{Prompt, output::TextDecodeOptions};
425
+ use vllm_tokenizer::{Tokenizer, test_utils::TestTokenizer};
426
427
use super::prepare_chat_request;
428
use crate::lora::LoraModelResolution;
429
use crate::routes::openai::chat_completions::types::{
430
AssistantRole, ChatCompletionMessage, ChatCompletionRequest,
431
};
432
+ use crate::routes::openai::utils::structured_outputs::{JsonSchemaFormat, ResponseFormat};
433
use crate::routes::openai::utils::types::{
434
AudioUrl, ChatMessage, ContentPart, Function, FunctionCallResponse, ImageUrl, InputAudio,
435
MessageContent, StreamOptions, Tool, ToolCall, ToolChoice, ToolChoiceValue, VideoUrl,
@@ -473,6 +486,60 @@ mod tests {
486
assert!(prepared.chat_request.parallel_tool_calls);
487
}
488
489
+ #[test]
490
+ fn prepare_chat_request_passes_response_format_to_kimi_k3_renderer() {
491
+ let mut request = base_request();
492
+ request.model = "moonshotai/Kimi-K3".to_string();
493
+ let response_format = ResponseFormat::JsonSchema {
494
+ json_schema: JsonSchemaFormat {
495
+ name: "answer".to_string(),
496
+ description: None,
497
+ schema: json!({
498
+ "type": "object",
499
+ "properties": {
500
+ "answer": {"type": "string"}
501
+ },
502
+ "required": ["answer"]
503
+ }),
504
+ strict: Some(true),
505
+ },
506
+ };
507
+ request.response_format = Some(response_format.clone());
508
+ let template_response_format = json!({"type": "json_object"});
509
+ request.chat_template_kwargs = Some(HashMap::from([(
510
+ "response_format".to_string(),
511
+ template_response_format.clone(),
512
+ )]));
513
+
514
+ let prepared = prepare_chat_request(
515
+ request,
516
+ &served(&["moonshotai/Kimi-K3"]),
517
+ ResolvedRequestContext::default(),
518
+ )
519
+ .expect("request is valid");
520
+
521
+ assert_eq!(
522
+ prepared.chat_request.chat_options.response_format.as_ref(),
523
+ Some(&serde_json::to_value(response_format).unwrap())
524
+ );
525
+ assert_eq!(
526
+ prepared.chat_request.chat_options.template_kwargs.get("response_format"),
527
+ Some(&template_response_format)
528
+ );
529
+
530
+ let tokenizer = Arc::new(TestTokenizer::new());
531
+ let prompt = KimiK3ChatRenderer::new(tokenizer.clone())
532
+ .render(&prepared.chat_request)
533
+ .expect("Kimi K3 rendering succeeds")
534
+ .prompt;
535
+ let Prompt::TokenIds(prompt_token_ids) = prompt else {
536
+ panic!("Kimi K3 renders token IDs");
537
+ };
538
+ let prompt = tokenizer.decode(&prompt_token_ids, false).expect("Kimi K3 prompt decodes");
539
+ assert!(prompt.contains("response_format=json_schema"));
540
+ assert!(prompt.contains(r#""answer":{"type":"string"}"#));
541
+ }
542
+
543
#[test]
544
fn prepare_chat_request_maps_text_parts() {
545
let mut request = base_request();
rust/src/server/src/routes/tokenize/types.rs
+1
@@ -82,6 +82,7 @@ impl TokenizeChatRequest {
82
generation_prompt_mode,
83
chat_template: self.chat_template,
84
reasoning_effort: None,
85
+ response_format: None,
86
template_kwargs: self.chat_template_kwargs.unwrap_or_default(),
87
},
88
tools: convert_tools(self.tools)?,
rust/src/tokenizer/src/tiktoken.rs
+3
-1
@@ -532,7 +532,7 @@ fn detect_bpe_pattern(config: &TiktokenModelConfig) -> &'static str {
532
let model_type = config.effective_model_type();
533
534
match model_type {
535
- Some("kimi" | "kimi_k2" | "kimi_k25" | "deepseek_v3") => KIMI_PATTERN,
535
+ Some("kimi" | "kimi_k2" | "kimi_k25" | "kimi_k3" | "deepseek_v3") => KIMI_PATTERN,
536
_ => CL100K_BASE_PATTERN,
537
}
538
}
@@ -817,6 +817,7 @@ mod tests {
817
#[test]
818
fn tiktoken_detects_kimi_pattern_from_model_type() {
819
let kimi = config_json!({ "model_type": "kimi_k25" });
820
+ let kimi_k3 = config_json!({ "model_type": "kimi_k3" });
821
let baseten_kimi = config_json!({ "model_type": "deepseek_v3" });
822
let nested_kimi = config_json!({
823
"model_type": "composite_wrapper",
@@ -830,6 +831,7 @@ mod tests {
831
let missing = config_json!({ "text_config": {} });
832
833
assert_eq!(detect_bpe_pattern(&kimi), KIMI_PATTERN);
834
+ assert_eq!(detect_bpe_pattern(&kimi_k3), KIMI_PATTERN);
835
assert_eq!(detect_bpe_pattern(&baseten_kimi), KIMI_PATTERN);
836
assert_eq!(detect_bpe_pattern(&nested_kimi), CL100K_BASE_PATTERN);
837
assert_eq!(detect_bpe_pattern(&generic), CL100K_BASE_PATTERN);