[Frontend][Bugfix] Use default tool call IDs for Kimi K3 for conversation-level uniqueness (#50420)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jul 31, 2026 at 10:02 UTC
ab98034d4ccccd62e814ebcdc3f3831586b1c8d7
5 files changed
+55
-53
rust/src/chat/src/renderer/kimi_k3/encoding.rs
+2
-2
@@ -126,7 +126,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -
126
resolved.sort_by_key(|item| (item.0, item.1));
127
}
128
129
- for (xtml_index, (_, _, content, name)) in resolved.into_iter().enumerate() {
129
+ for (position, _, content, name) in resolved {
130
let tool_name = name.as_deref().ok_or_else(|| {
131
Error::ChatTemplate(
132
"Kimi K3 tool messages need a resolvable tool name: \
@@ -135,7 +135,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -
135
.to_string(),
136
)
137
})?;
138
- write_tool_message(out, tool_name, xtml_index + 1, &content)?;
138
+ write_tool_message(out, tool_name, position, &content)?;
139
}
140
Ok(())
141
};
rust/src/chat/src/renderer/kimi_k3/tests.rs
+30
-1
@@ -14,11 +14,11 @@ use vllm_tokenizer::Tokenizer;
14
use vllm_tokenizer::test_utils::TestTokenizer;
15
16
use super::KimiK3ChatRenderer;
17
-use crate::AssistantContentBlock;
17
use crate::ChatRenderer;
18
use crate::renderer::kimi_k3::encoding::{CLOSE, END_OF_MSG, IMAGE_PLACEHOLDER, OPEN, SEP};
19
use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
20
use crate::request::{ChatContentPart, ChatMessage, GenerationPromptMode, ReasoningEffort};
21
+use crate::{AssistantContentBlock, AssistantToolCall};
22
23
const OPEN_ID: u32 = 256;
24
const CLOSE_ID: u32 = 257;
@@ -178,6 +178,35 @@ fn non_thinking_history_omits_reasoning_channel() {
178
));
179
}
180
181
+#[test]
182
+fn partial_tool_results_keep_assistant_call_position() {
183
+ let mut request = crate::request::ChatRequest::for_test();
184
+ request.messages = vec![
185
+ ChatMessage::assistant_blocks(vec![
186
+ AssistantContentBlock::ToolCall(AssistantToolCall {
187
+ id: "call-a".to_string(),
188
+ name: "weather".to_string(),
189
+ arguments: "{}".to_string(),
190
+ }),
191
+ AssistantContentBlock::ToolCall(AssistantToolCall {
192
+ id: "call-b".to_string(),
193
+ name: "search".to_string(),
194
+ arguments: "{}".to_string(),
195
+ }),
196
+ ]),
197
+ ChatMessage::tool_response("result-b", "call-b"),
198
+ ];
199
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
200
+
201
+ let rendered = render_request(&request);
202
+
203
+ assert!(rendered.contains("<|open|>call tool=\"search\" index=\"2\"<|sep|>"));
204
+ assert!(
205
+ rendered
206
+ .contains("<|open|>message role=\"tool\" tool=\"search\" index=\"2\"<|sep|>result-b")
207
+ );
208
+}
209
+
210
#[test]
211
fn defaults_thinking_effort_to_max() {
212
let rendered = render_request(&crate::request::ChatRequest::for_test());
rust/src/parser/src/unified/kimi_k3.rs
+14
-37
@@ -148,9 +148,8 @@ enum KimiK3Mode {
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>,
151
+ /// Number of calls emitted in the current response.
152
+ emitted_call_count: usize,
153
tokenizer: DynTokenizer,
154
open_token_id: u32,
155
sep_token_id: u32,
@@ -165,7 +164,7 @@ impl KimiK3UnifiedParser {
164
Ok(Self {
165
buffer: String::new(),
166
mode: KimiK3Mode::default(),
168
- call_ids: Vec::new(),
167
+ emitted_call_count: 0,
168
tokenizer,
169
open_token_id,
170
sep_token_id,
@@ -226,7 +225,7 @@ impl KimiK3UnifiedParser {
225
}
226
KimiK3Event::CallComplete { arguments } => {
227
let mode = std::mem::replace(&mut self.mode, KimiK3Mode::Tools);
229
- let KimiK3Mode::Call { name, index, .. } = mode else {
228
+ let KimiK3Mode::Call { name, .. } = mode else {
229
return Err(parsing_failed!(
230
"Kimi K3 call completion without an active tool call"
231
));
@@ -237,8 +236,8 @@ impl KimiK3UnifiedParser {
236
return Ok(());
237
}
238
240
- let tool_index = self.call_ids.len();
241
- self.call_ids.push(tool_call_id_for(&name, index.as_deref()));
239
+ let tool_index = self.emitted_call_count;
240
+ self.emitted_call_count += 1;
241
output.push_call(ToolCallDelta {
242
tool_index,
243
name: Some(name),
@@ -251,7 +250,7 @@ impl KimiK3UnifiedParser {
250
251
fn reset_state(&mut self) -> String {
252
self.mode = KimiK3Mode::Idle;
254
- self.call_ids.clear();
253
+ self.emitted_call_count = 0;
254
std::mem::take(&mut self.buffer)
255
}
256
}
@@ -266,7 +265,7 @@ impl UnifiedParser for KimiK3UnifiedParser {
265
266
fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
267
self.buffer.clear();
269
- self.call_ids.clear();
268
+ self.emitted_call_count = 0;
269
self.initialize_mode(prompt_token_ids);
270
Ok(())
271
}
@@ -279,10 +278,6 @@ impl UnifiedParser for KimiK3UnifiedParser {
278
Some(&KIMI_K3_STRUCTURAL_TAG_BUILDER)
279
}
280
282
- fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
283
- self.call_ids.get(tool_index).map(String::as_str)
284
- }
285
-
281
fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> {
282
self.buffer.push_str(chunk);
283
@@ -313,7 +308,6 @@ impl UnifiedParser for KimiK3UnifiedParser {
308
}
309
}
310
316
- // Keep call_ids so tool_call_id() stays available after the stream ends.
311
self.mode = KimiK3Mode::Idle;
312
Ok(output)
313
}
@@ -323,20 +317,6 @@ impl UnifiedParser for KimiK3UnifiedParser {
317
}
318
}
319
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
-
320
/// Parse one Kimi K3 event from buffered streaming input.
321
fn parse_next_kimi_k3_event(
322
input: &mut KimiK3Input<'_>,
@@ -762,7 +742,6 @@ mod tests {
742
"hours": [8, 20],
743
})
744
);
765
- assert_eq!(parser.tool_call_id(0), Some("get_weather:0"));
745
}
746
747
#[test]
@@ -929,8 +908,6 @@ mod tests {
908
assert_eq!(calls[1].tool_index, 1);
909
assert_eq!(calls[1].name.as_deref(), Some("get_time"));
910
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"));
911
}
912
913
#[test]
@@ -1018,11 +995,10 @@ mod tests {
995
assert_eq!(calls.len(), 1);
996
assert_eq!(calls[0].tool_index, 0);
997
assert_eq!(calls[0].name.as_deref(), Some("real"));
1021
- assert_eq!(parser.tool_call_id(0), Some("real:1"));
998
}
999
1000
#[test]
1025
- fn kimi_k3_tool_call_id_follows_index_attribute() {
1001
+ fn kimi_k3_tool_indices_ignore_xtml_index_attribute() {
1002
let tools_body = format!(
1003
"{}{}{}",
1004
call("tool=\"first\" index=\"3\"", ""),
@@ -1032,11 +1008,12 @@ mod tests {
1008
let text = thinking_output("t", "", &tools_body);
1009
1010
let mut parser = test_parser();
1035
- parser.parse_complete(&text).unwrap();
1011
+ let output = parser.parse_complete(&text).unwrap();
1012
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"));
1013
+ assert_eq!(
1014
+ output.calls().iter().map(|call| call.tool_index).collect::<Vec<_>>(),
1015
+ [0, 1, 2]
1016
+ );
1017
}
1018
1019
#[test]
tests/tool_use/test_kimi_k3_tool_parser.py
+9
-3
@@ -141,7 +141,6 @@ def test_extract_tool_calls_with_response_and_typed_arguments():
141
assert extracted.content == "answer"
142
assert len(extracted.tool_calls) == 1
143
tool_call = extracted.tool_calls[0]
144
- assert tool_call.id == "calc:0"
144
assert tool_call.function.name == "calc"
145
assert json.loads(tool_call.function.arguments) == {
146
"x": 1,
@@ -168,7 +167,6 @@ def test_delegating_parser_preserves_tool_calls_after_reasoning():
167
assert content == "answer"
168
assert tool_calls is not None
169
assert len(tool_calls) == 1
171
- assert tool_calls[0].id == "calc:0"
170
assert tool_calls[0].name == "calc"
171
assert json.loads(tool_calls[0].arguments) == {"x": 1}
172
@@ -364,11 +362,19 @@ def test_streaming_split_markers_do_not_leak():
362
assert OPEN not in content
363
assert SEP not in content
364
assert len(tool_deltas) == 1
367
- assert tool_deltas[0].id == "calc:0"
365
assert tool_deltas[0].function.name == "calc"
366
assert json.loads(tool_deltas[0].function.arguments) == {"x": 1}
367
368
369
+def test_tool_call_ids_are_unique_across_messages():
370
+ output = _tools(_call("calc", 1))
371
+
372
+ first = KimiK3ToolParser(DummyTokenizer()).extract_tool_calls(output, _request())
373
+ second = KimiK3ToolParser(DummyTokenizer()).extract_tool_calls(output, _request())
374
+
375
+ assert first.tool_calls[0].id != second.tool_calls[0].id
376
+
377
+
378
def test_streaming_consumed_response_prefix_no_call_keeps_content():
379
parser = KimiK3ToolParser(DummyTokenizer())
380
request = _request()
vllm/tool_parsers/kimi_k3_tool_parser.py
-10
@@ -218,7 +218,6 @@ class KimiK3ToolParser(ToolParser):
218
"""
219
call_attrs = self._attrs(attrs)
220
tool_name = call_attrs.get("tool", "")
221
- tool_index = call_attrs.get("index", "")
221
arguments: dict = {}
222
for arg_match in self._arg_re.finditer(body):
223
arg_attrs = self._attrs(arg_match["attrs"])
@@ -234,16 +233,7 @@ class KimiK3ToolParser(ToolParser):
233
arguments[key] = raw_value
234
if not tool_name:
235
return None
237
- tool_call_id = tool_name
238
- if tool_index:
239
- try:
240
- tool_call_id = f"{tool_name}:{int(tool_index) - 1}"
241
- except ValueError:
242
- tool_call_id = f"{tool_name}:{tool_index}"
243
- # id uses the API-side zero-based call ordinal; XTML's message index
244
- # stays one-based when rendering tool result messages.
236
return ToolCall(
246
- id=tool_call_id,
237
type="function",
238
function=FunctionCall(
239
name=tool_name,