A high-throughput and memory-efficient inference and serving engine for LLMs
[Rust Frontend] Support Kimi K2 tool call IDs (#44901)
Maria Guevara committed
Jun 9, 2026 at 13:31 UTC
1c23c4203007d16d96b1f07847f5bf1b663ad0d7
5 files changed
+147
-8
rust/src/chat/src/output/default/tool.rs
+103
-1
@@ -121,7 +121,11 @@ impl ToolState {
121
None => true,
122
};
123
if is_new_tool {
124
- let id = generate_tool_call_id();
124
+ let id = self
125
+ .parser
126
+ .tool_call_id(item.tool_index)
127
+ .map(str::to_string)
128
+ .unwrap_or_else(generate_tool_call_id);
129
self.open_call_index = Some(item.tool_index);
130
events.push(AssistantEvent::ToolCallStart { id, name });
131
}
@@ -291,6 +295,11 @@ mod tests {
295
buffered: String,
296
}
297
298
+ struct IdScriptedParser {
299
+ output: ToolParserOutput,
300
+ tool_call_id: Option<String>,
301
+ }
302
+
303
impl ToolParser for FailingParser {
304
fn create(_tools: &[ChatTool]) -> vllm_tool_parser::Result<Box<dyn ToolParser>>
305
where
@@ -351,6 +360,35 @@ mod tests {
360
}
361
}
362
363
+ impl ToolParser for IdScriptedParser {
364
+ fn create(_tools: &[ChatTool]) -> vllm_tool_parser::Result<Box<dyn ToolParser>>
365
+ where
366
+ Self: Sized + 'static,
367
+ {
368
+ Ok(Box::new(Self {
369
+ output: ToolParserOutput::default(),
370
+ tool_call_id: None,
371
+ }))
372
+ }
373
+
374
+ fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
375
+ (tool_index == 0).then_some(self.tool_call_id.as_deref()).flatten()
376
+ }
377
+
378
+ fn parse_into(&mut self, _chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
379
+ output.append(std::mem::take(&mut self.output));
380
+ Ok(())
381
+ }
382
+
383
+ fn finish(&mut self) -> Result<ToolParserOutput> {
384
+ Ok(ToolParserOutput::default())
385
+ }
386
+
387
+ fn reset(&mut self) -> String {
388
+ String::new()
389
+ }
390
+ }
391
+
392
impl ToolParser for PartialThenFailParser {
393
fn create(_tools: &[ChatTool]) -> vllm_tool_parser::Result<Box<dyn ToolParser>>
394
where
@@ -505,6 +543,70 @@ mod tests {
543
assert!(matches!(events[3], AssistantEvent::Done { .. }));
544
}
545
546
+ #[tokio::test]
547
+ async fn tool_stream_preserves_parser_provided_tool_call_id() {
548
+ let events = stream::iter(vec![Ok(ContentEvent::TextDelta {
549
+ kind: AssistantBlockKind::Text,
550
+ delta: "ignored".to_string(),
551
+ })]);
552
+ let parser = IdScriptedParser {
553
+ output: ToolParserOutput {
554
+ normal_text: String::new(),
555
+ calls: vec![crate::parser::tool::ToolCallDelta {
556
+ tool_index: 0,
557
+ name: Some("get_weather".to_string()),
558
+ arguments: "{}".to_string(),
559
+ }],
560
+ },
561
+ tool_call_id: Some("functions.get_weather:0".to_string()),
562
+ };
563
+
564
+ let events = tool_event_stream(events, Some(Box::new(parser)))
565
+ .collect::<Vec<_>>()
566
+ .await
567
+ .into_iter()
568
+ .collect::<crate::Result<Vec<_>>>()
569
+ .unwrap();
570
+
571
+ assert!(matches!(
572
+ &events[0],
573
+ AssistantEvent::ToolCallStart { id, name }
574
+ if id == "functions.get_weather:0" && name == "get_weather"
575
+ ));
576
+ }
577
+
578
+ #[tokio::test]
579
+ async fn tool_stream_generates_tool_call_id_when_parser_omits_one() {
580
+ let events = stream::iter(vec![Ok(ContentEvent::TextDelta {
581
+ kind: AssistantBlockKind::Text,
582
+ delta: "ignored".to_string(),
583
+ })]);
584
+ let parser = IdScriptedParser {
585
+ output: ToolParserOutput {
586
+ normal_text: String::new(),
587
+ calls: vec![crate::parser::tool::ToolCallDelta {
588
+ tool_index: 0,
589
+ name: Some("get_weather".to_string()),
590
+ arguments: "{}".to_string(),
591
+ }],
592
+ },
593
+ tool_call_id: None,
594
+ };
595
+
596
+ let events = tool_event_stream(events, Some(Box::new(parser)))
597
+ .collect::<Vec<_>>()
598
+ .await
599
+ .into_iter()
600
+ .collect::<crate::Result<Vec<_>>>()
601
+ .unwrap();
602
+
603
+ assert!(matches!(
604
+ &events[0],
605
+ AssistantEvent::ToolCallStart { id, name }
606
+ if id.starts_with("call_") && name == "get_weather"
607
+ ));
608
+ }
609
+
610
#[tokio::test]
611
async fn real_buffered_parser_error_matches_streaming_and_non_streaming() {
612
let prefix = "I will check both.\n";
rust/src/chat/src/output/mod.rs
-2
@@ -128,8 +128,6 @@ trait_set! {
128
129
/// Generate the northbound tool-call ID using the OpenAI-style `call_<id>`
130
/// format.
131
-// TODO: support other ID scheme like Kimi-K2's
132
-// `functions.{name}:{global_index}`.
131
pub(crate) fn generate_tool_call_id() -> String {
132
format!("call_{}", &Uuid::new_v4().simple().to_string()[..24])
133
}
rust/src/chat/tests/roundtrip.rs
+1
-2
@@ -170,8 +170,7 @@ roundtrip_tests! {
170
glm47 => [reasoning_and_content, tool_call_mix],
171
172
// Note: Kimi K2.5 strips the reasoning content in history.
173
- // TODO: we don't respect model-generated tool call id now so `tool_call_mix` cannot pass.
174
- // kimi_k25 => [tool_call_mix],
173
+ kimi_k25 => [tool_call_mix],
174
}
175
176
/// Run the fixed reasoning+content fixture for one model/parser case.
rust/src/tool-parser/src/kimi_k2.rs
+37
-3
@@ -1,3 +1,5 @@
1
+use std::collections::BTreeMap;
2
+
3
use winnow::ascii::{digit1, multispace0 as ws0};
4
use winnow::combinator::{alt, eof, repeat, seq};
5
use winnow::prelude::*;
@@ -33,6 +35,7 @@ enum KimiK2Event {
35
ToolCallsStart,
36
ToolCallStart,
37
ToolCallHeader {
38
+ tool_call_id: String,
39
function_name: String,
40
function_index: usize,
41
},
@@ -60,6 +63,7 @@ pub struct KimiK2ToolParser {
63
buffer: String,
64
mode: KimiK2Mode,
65
active_tool_index: Option<usize>,
66
+ call_ids: BTreeMap<usize, String>,
67
}
68
69
impl KimiK2ToolParser {
@@ -69,6 +73,7 @@ impl KimiK2ToolParser {
73
buffer: String::new(),
74
mode: KimiK2Mode::Text,
75
active_tool_index: None,
76
+ call_ids: BTreeMap::new(),
77
}
78
}
79
@@ -81,6 +86,7 @@ impl KimiK2ToolParser {
86
KimiK2Event::ToolCallsStart => self.mode = KimiK2Mode::ToolBlock,
87
KimiK2Event::ToolCallStart => self.mode = KimiK2Mode::Header,
88
KimiK2Event::ToolCallHeader {
89
+ tool_call_id,
90
function_name,
91
function_index,
92
} => {
@@ -89,6 +95,7 @@ impl KimiK2ToolParser {
95
self.mode = KimiK2Mode::Arguments {
96
json_scan: JsonObjectScanState::default(),
97
};
98
+ self.call_ids.insert(tool_index, tool_call_id);
99
output.calls.push(ToolCallDelta {
100
tool_index,
101
name: Some(function_name),
@@ -123,6 +130,7 @@ impl KimiK2ToolParser {
130
fn reset(&mut self) -> String {
131
self.mode = KimiK2Mode::Text;
132
self.active_tool_index = None;
133
+ self.call_ids.clear();
134
std::mem::take(&mut self.buffer)
135
}
136
}
@@ -139,6 +147,10 @@ impl ToolParser for KimiK2ToolParser {
147
true
148
}
149
150
+ fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
151
+ self.call_ids.get(&tool_index).map(String::as_str)
152
+ }
153
+
154
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
155
self.buffer.push_str(chunk);
156
@@ -232,16 +244,18 @@ fn tool_call_end_event(input: &mut KimiK2Input<'_>) -> ModalResult<KimiK2Event>
244
245
/// Parse a Kimi K2 tool-call header before the argument marker.
246
fn tool_call_header_event(input: &mut KimiK2Input<'_>) -> ModalResult<KimiK2Event> {
235
- let (header, _) = (
247
+ let (raw_header, _) = (
248
take_until(1.., TOOL_CALL_ARGUMENT_START),
249
literal(TOOL_CALL_ARGUMENT_START),
250
)
251
.parse_next(input)?;
252
241
- let mut header_input = header;
253
+ let tool_call_id = raw_header.trim().to_string();
254
+ let mut header_input = raw_header;
255
let (header, _, _) = (tool_header, ws0, eof).parse_next(&mut header_input)?;
256
257
Ok(KimiK2Event::ToolCallHeader {
258
+ tool_call_id,
259
function_name: header.function_name,
260
function_index: header.function_index,
261
})
@@ -502,6 +516,25 @@ mod tests {
516
.assert_debug_eq(&output);
517
}
518
519
+ #[test]
520
+ fn kimi_k2_preserves_model_generated_tool_call_ids() {
521
+ let mut parser = KimiK2ToolParser::new(&test_tools());
522
+ let input = build_tool_section(&[
523
+ build_tool_call("get_weather", 0, r#"{"location":"Shanghai"}"#),
524
+ build_tool_call("add", 1, r#"{"x":1,"y":2}"#),
525
+ ]);
526
+
527
+ for chunk in split_by_chars(&input, 7) {
528
+ parser.parse_chunk(chunk).unwrap();
529
+ }
530
+
531
+ // IDs are available after parsing but before finish(), which calls reset().
532
+ assert_eq!(parser.tool_call_id(0), Some("functions.get_weather:0"));
533
+ assert_eq!(parser.tool_call_id(1), Some("functions.add:1"));
534
+ parser.finish().unwrap();
535
+ assert_eq!(parser.tool_call_id(0), None);
536
+ }
537
+
538
#[test]
539
fn kimi_k2_accepts_non_functions_header_prefix() {
540
let mut parser = KimiK2ToolParser::new(&test_tools());
@@ -509,9 +542,10 @@ mod tests {
542
"{TOOL_CALLS_START}{TOOL_CALL_START}api.tools.search:42{TOOL_CALL_ARGUMENT_START}{{}}{TOOL_CALL_END}{TOOL_CALLS_END}"
543
);
544
512
- let output = parser.parse_complete(&input).unwrap();
545
+ let output = parser.parse_chunk(&input).unwrap().coalesce_calls();
546
547
assert_eq!(output.calls[0].tool_index, 42);
548
+ assert_eq!(parser.tool_call_id(42), Some("api.tools.search:42"));
549
assert_eq!(output.calls[0].name.as_deref(), Some("search"));
550
assert_eq!(output.calls[0].arguments, "{}");
551
}
rust/src/tool-parser/src/lib.rs
+6
@@ -121,6 +121,12 @@ pub trait ToolParser: Send {
121
false
122
}
123
124
+ /// Return the parser-provided ID for a tool call by index, if the model
125
+ /// emitted one.
126
+ fn tool_call_id(&self, _tool_index: usize) -> Option<&str> {
127
+ None
128
+ }
129
+
130
/// Feed one decoded text delta into the parser, appending committed output
131
/// into `output`.
132
///