A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 4, 23:45 UTC
[Bugfix] Kimi-K2 tool parser streaming - fix token leakage, argument truncation, and content dropping (#38579)
Signed-off-by: sfeng33 <4florafeng@gmail.com>
Flora Feng committed
Apr 19, 2026 at 04:30 UTC
03ce1c6ed908788cf508219b666720783a723123
2 files changed
+684
-1405
tests/tool_parsers/test_kimi_k2_tool_parser.py
+525
-921
@@ -3,14 +3,20 @@
3
# ruff: noqa: E501
4
5
import json
6
+from unittest.mock import MagicMock
7
8
import pytest
9
9
-from vllm.entrypoints.openai.engine.protocol import FunctionCall, ToolCall
10
+from tests.tool_parsers.utils import (
11
+ run_tool_extraction,
12
+ run_tool_extraction_streaming,
13
+)
14
+from vllm.entrypoints.openai.chat_completion.protocol import (
15
+ ChatCompletionRequest,
16
+)
17
from vllm.tokenizers import get_tokenizer
18
from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser
19
13
-# Use a common model that is likely to be available
20
MODEL = "moonshotai/Kimi-K2-Instruct"
21
22
@@ -20,959 +26,557 @@ def kimi_k2_tokenizer():
26
27
28
@pytest.fixture
23
-def kimi_k2_tool_parser(kimi_k2_tokenizer):
29
+def parser(kimi_k2_tokenizer):
30
return KimiK2ToolParser(kimi_k2_tokenizer)
31
32
27
-def assert_tool_calls(
28
- actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
29
-):
30
- assert len(actual_tool_calls) == len(expected_tool_calls)
33
+SECTION_BEGIN = "<|tool_calls_section_begin|>"
34
+SECTION_END = "<|tool_calls_section_end|>"
35
+TOOL_BEGIN = "<|tool_call_begin|>"
36
+TOOL_END = "<|tool_call_end|>"
37
+ARG_BEGIN = "<|tool_call_argument_begin|>"
38
+
39
+
40
+def _tool(tool_id: str, args: str) -> str:
41
+ return f"{TOOL_BEGIN}{tool_id} {ARG_BEGIN}{args}{TOOL_END}"
42
32
- for actual_tool_call, expected_tool_call in zip(
33
- actual_tool_calls, expected_tool_calls
34
- ):
35
- assert actual_tool_call.type == "function"
36
- assert actual_tool_call.function == expected_tool_call.function
37
-
38
- # assert tool call id format: should contain function name and numeric index
39
- # Format can be either "functions.func_name:0" or "func_name:0"
40
- assert actual_tool_call.id.split(":")[-1].isdigit()
41
- assert (
42
- actual_tool_call.id.split(":")[0].split(".")[-1]
43
- == expected_tool_call.function.name
44
- )
43
44
+def _wrap(*tool_strs: str) -> str:
45
+ return SECTION_BEGIN + "".join(tool_strs) + SECTION_END
46
47
-def run_streaming_sequence(parser, deltas):
48
- """Helper to simulate a streaming sequence and return results."""
49
- previous_text = ""
50
- previous_token_ids: list[int] = []
51
- results = []
52
-
53
- for delta_text, delta_token_ids in deltas:
54
- current_text = previous_text + delta_text
55
- current_token_ids = previous_token_ids + delta_token_ids
56
-
57
- result = parser.extract_tool_calls_streaming(
58
- previous_text=previous_text,
59
- current_text=current_text,
60
- delta_text=delta_text,
61
- previous_token_ids=previous_token_ids,
62
- current_token_ids=current_token_ids,
63
- delta_token_ids=delta_token_ids,
64
- request=None,
47
+
48
+class TestExtractToolCalls:
49
+ def test_no_tools(self, parser):
50
+ content, tool_calls = run_tool_extraction(
51
+ parser, "This is a test", streaming=False
52
)
66
- results.append(result)
67
-
68
- previous_text = current_text
69
- previous_token_ids = current_token_ids
70
-
71
- return results
72
-
73
-
74
-def test_extract_tool_calls_no_tools(kimi_k2_tool_parser):
75
- model_output = "This is a test"
76
- extracted_tool_calls = kimi_k2_tool_parser.extract_tool_calls(
77
- model_output, request=None
78
- ) # type: ignore[arg-type]
79
- assert not extracted_tool_calls.tools_called
80
- assert extracted_tool_calls.tool_calls == []
81
- assert extracted_tool_calls.content == model_output
82
-
83
-
84
-@pytest.mark.parametrize(
85
- ids=[
86
- "tool_call_with_content_before",
87
- "multi_tool_call_with_content_before",
88
- "concatenated_tool_calls_bug_fix",
89
- "three_concatenated_tool_calls",
90
- "mixed_spacing_tool_calls",
91
- "angle_brackets_in_json",
92
- "newlines_in_json",
93
- ],
94
- argnames=["model_output", "expected_tool_calls", "expected_content"],
95
- argvalues=[
96
- (
97
- """I'll help you check the weather. <|tool_calls_section_begin|> <|tool_call_begin|>
98
-functions.get_weather:0 <|tool_call_argument_begin|> {"city": "Beijing"} <|tool_call_end|> <|tool_calls_section_end|>""",
99
- [
100
- ToolCall(
101
- id="functions.get_weather:0",
102
- function=FunctionCall(
103
- name="get_weather",
104
- arguments=json.dumps(
105
- {
106
- "city": "Beijing",
107
- },
108
- ),
109
- ),
110
- type="function",
111
- )
112
- ],
113
- "I'll help you check the weather. ",
114
- ),
115
- (
116
- """I'll help you check the weather. <|tool_calls_section_begin|> <|tool_call_begin|>
117
-functions.get_weather:0 <|tool_call_argument_begin|> {"city": "Beijing"} <|tool_call_end|> <|tool_call_begin|>
118
-functions.get_weather:1 <|tool_call_argument_begin|> {"city": "Shanghai"} <|tool_call_end|> <|tool_calls_section_end|>""",
119
- [
120
- ToolCall(
121
- id="functions.get_weather:0",
122
- function=FunctionCall(
123
- name="get_weather",
124
- arguments=json.dumps(
125
- {
126
- "city": "Beijing",
127
- },
128
- ),
129
- ),
130
- type="function",
131
- ),
132
- ToolCall(
133
- id="functions.get_weather:1",
134
- function=FunctionCall(
135
- name="get_weather",
136
- arguments=json.dumps(
137
- {
138
- "city": "Shanghai",
139
- },
140
- ),
141
- ),
142
- type="function",
143
- ),
144
- ],
145
- "I'll help you check the weather. ",
146
- ),
147
- (
148
- """I'll get the weather and news for LA today. First, let me get the weather using Los Angeles coordinates, and then get the latest news. <|tool_calls_section_begin|><|tool_call_begin|>functions.get_weather:0<|tool_call_argument_begin|>{"latitude": 34.0522, "longitude": -118.2437}<|tool_call_end|><|tool_call_begin|>functions.get_news:1<|tool_call_argument_begin|>{"content": "Los Angeles today"}<|tool_call_end|><|tool_calls_section_end|>""",
149
- [
150
- ToolCall(
151
- id="functions.get_weather:0",
152
- function=FunctionCall(
153
- name="get_weather",
154
- arguments=json.dumps(
155
- {"latitude": 34.0522, "longitude": -118.2437}
156
- ),
157
- ),
158
- type="function",
159
- ),
160
- ToolCall(
161
- id="functions.get_news:1",
162
- function=FunctionCall(
163
- name="get_news",
164
- arguments=json.dumps({"content": "Los Angeles today"}),
165
- ),
166
- type="function",
167
- ),
168
- ],
169
- "I'll get the weather and news for LA today. First, let me get the weather using Los Angeles coordinates, and then get the latest news. ",
170
- ),
171
- (
172
- """I'll help you with multiple tasks. <|tool_calls_section_begin|><|tool_call_begin|>functions.get_weather:0<|tool_call_argument_begin|>{"city": "New York"}<|tool_call_end|><|tool_call_begin|>functions.get_news:1<|tool_call_argument_begin|>{"topic": "technology"}<|tool_call_end|><|tool_call_begin|>functions.send_email:2<|tool_call_argument_begin|>{"to": "user@example.com", "subject": "Daily Update"}<|tool_call_end|><|tool_calls_section_end|>""",
173
- [
174
- ToolCall(
175
- id="functions.get_weather:0",
176
- function=FunctionCall(
177
- name="get_weather",
178
- arguments=json.dumps({"city": "New York"}),
179
- ),
180
- type="function",
53
+ assert content == "This is a test"
54
+ assert tool_calls == []
55
+
56
+ @pytest.mark.parametrize(
57
+ "model_output, expected_names, expected_args_list, expected_content",
58
+ [
59
+ pytest.param(
60
+ "I'll check. "
61
+ + _wrap(_tool("functions.get_weather:0", '{"city": "Beijing"}')),
62
+ ["get_weather"],
63
+ [{"city": "Beijing"}],
64
+ "I'll check. ",
65
+ id="single_tool_call",
66
+ ),
67
+ pytest.param(
68
+ "Compare weather. "
69
+ + _wrap(
70
+ _tool("functions.get_weather:0", '{"city": "Beijing"}'),
71
+ _tool("functions.get_weather:1", '{"city": "Shanghai"}'),
72
),
182
- ToolCall(
183
- id="functions.get_news:1",
184
- function=FunctionCall(
185
- name="get_news",
186
- arguments=json.dumps({"topic": "technology"}),
73
+ ["get_weather", "get_weather"],
74
+ [{"city": "Beijing"}, {"city": "Shanghai"}],
75
+ "Compare weather. ",
76
+ id="parallel_tool_calls",
77
+ ),
78
+ pytest.param(
79
+ "Multiple tasks. "
80
+ + _wrap(
81
+ _tool("functions.get_weather:0", '{"city": "New York"}'),
82
+ _tool("functions.get_news:1", '{"topic": "technology"}'),
83
+ _tool(
84
+ "functions.send_email:2",
85
+ '{"to": "user@example.com", "subject": "Daily Update"}',
86
),
188
- type="function",
87
),
190
- ToolCall(
191
- id="functions.send_email:2",
192
- function=FunctionCall(
193
- name="send_email",
194
- arguments=json.dumps(
195
- {"to": "user@example.com", "subject": "Daily Update"}
196
- ),
197
- ),
198
- type="function",
88
+ ["get_weather", "get_news", "send_email"],
89
+ [
90
+ {"city": "New York"},
91
+ {"topic": "technology"},
92
+ {"to": "user@example.com", "subject": "Daily Update"},
93
+ ],
94
+ "Multiple tasks. ",
95
+ id="three_tool_calls",
96
+ ),
97
+ pytest.param(
98
+ "Process HTML. "
99
+ + _wrap(
100
+ _tool("functions.process_html:0", '{"html": "<div>content</div>"}')
101
),
200
- ],
201
- "I'll help you with multiple tasks. ",
202
- ),
203
- (
204
- """Mixed spacing test. <|tool_calls_section_begin|> <|tool_call_begin|> functions.test:0 <|tool_call_argument_begin|> {} <|tool_call_end|><|tool_call_begin|>functions.test2:1<|tool_call_argument_begin|>{}<|tool_call_end|> <|tool_calls_section_end|>""",
205
- [
206
- ToolCall(
207
- id="functions.test:0",
208
- function=FunctionCall(
209
- name="test",
210
- arguments=json.dumps({}),
211
- ),
212
- type="function",
102
+ ["process_html"],
103
+ [{"html": "<div>content</div>"}],
104
+ "Process HTML. ",
105
+ id="angle_brackets_in_json",
106
+ ),
107
+ pytest.param(
108
+ "Formatted. "
109
+ + _wrap(
110
+ _tool(
111
+ "functions.process_data:0",
112
+ '{\n "name": "test",\n "value": 123\n}',
113
+ )
114
),
214
- ToolCall(
215
- id="functions.test2:1",
216
- function=FunctionCall(
217
- name="test2",
218
- arguments=json.dumps({}),
219
- ),
220
- type="function",
221
- ),
222
- ],
223
- "Mixed spacing test. ",
224
- ),
225
- (
226
- """I need to process HTML content. <|tool_calls_section_begin|><|tool_call_begin|>functions.process_html:0<|tool_call_argument_begin|>{"html": "<div>content</div>", "text": "normal text"}<|tool_call_end|><|tool_calls_section_end|>""",
227
- [
228
- ToolCall(
229
- id="functions.process_html:0",
230
- function=FunctionCall(
231
- name="process_html",
232
- arguments=json.dumps(
233
- {"html": "<div>content</div>", "text": "normal text"}
234
- ),
235
- ),
236
- type="function",
237
- )
238
- ],
239
- "I need to process HTML content. ",
240
- ),
241
- (
242
- """I need to process formatted JSON. <|tool_calls_section_begin|><|tool_call_begin|>functions.process_data:0<|tool_call_argument_begin|>{
243
- "name": "test",
244
- "value": 123,
245
- "nested": {
246
- "key": "value"
247
- }
248
-}<|tool_call_end|><|tool_calls_section_end|>""",
249
- [
250
- ToolCall(
251
- id="functions.process_data:0",
252
- function=FunctionCall(
253
- name="process_data",
254
- arguments=json.dumps(
255
- {"name": "test", "value": 123, "nested": {"key": "value"}},
256
- indent=2,
257
- ),
258
- ),
259
- type="function",
260
- )
261
- ],
262
- "I need to process formatted JSON. ",
263
- ),
264
- ],
265
-)
266
-def test_extract_tool_calls(
267
- kimi_k2_tool_parser, model_output, expected_tool_calls, expected_content
268
-):
269
- extracted_tool_calls = kimi_k2_tool_parser.extract_tool_calls(
270
- model_output, request=None
271
- ) # type: ignore[arg-type]
272
- assert extracted_tool_calls.tools_called
273
-
274
- assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
275
-
276
- assert extracted_tool_calls.content == expected_content
277
-
278
-
279
-def test_extract_tool_calls_invalid_json(kimi_k2_tool_parser):
280
- """we'll return every funcall result"""
281
- model_output = """I'll help you check the weather. <|tool_calls_section_begin|> <|tool_call_begin|>
282
-functions.invalid_get_weather:0 <|tool_call_argument_begin|> {"city": "Beijing" <|tool_call_end|> <|tool_call_begin|>
283
-functions.valid_get_weather:1 <|tool_call_argument_begin|> {"city": "Shanghai"} <|tool_call_end|> <|tool_calls_section_end|>"""
284
-
285
- extracted_tool_calls = kimi_k2_tool_parser.extract_tool_calls(
286
- model_output, request=None
287
- ) # type: ignore[arg-type]
288
-
289
- assert extracted_tool_calls.tools_called
290
- # Should extract only the valid JSON tool calls
291
- assert len(extracted_tool_calls.tool_calls) == 2
292
- assert extracted_tool_calls.tool_calls[0].function.name == "invalid_get_weather"
293
- assert extracted_tool_calls.tool_calls[1].function.name == "valid_get_weather"
294
-
295
-
296
-def test_extract_tool_calls_invalid_funcall(kimi_k2_tool_parser):
297
- """we'll return every funcall result"""
298
- model_output = """I'll help you check the weather. <|tool_calls_section_begin|> <|tool_call_begin|>
299
-functions.invalid_get_weather.0 <|tool_call_argument_begin|> {"city": "Beijing"} <|tool_call_end|> <|tool_call_begin|>
300
-functions.valid_get_weather:1 <|tool_call_argument_begin|> {"city": "Shanghai"} <|tool_call_end|> <|tool_calls_section_end|>"""
301
-
302
- extracted_tool_calls = kimi_k2_tool_parser.extract_tool_calls(
303
- model_output, request=None
304
- ) # type: ignore[arg-type]
305
-
306
- assert extracted_tool_calls.tools_called
307
- # Should extract only the valid JSON tool calls
308
- assert len(extracted_tool_calls.tool_calls) == 1
309
- assert extracted_tool_calls.tool_calls[0].function.name == "valid_get_weather"
310
-
311
-
312
-def test_streaming_basic_functionality(kimi_k2_tool_parser):
313
- """Test basic streaming functionality."""
314
- # Reset streaming state
315
- kimi_k2_tool_parser.current_tool_name_sent = False
316
- kimi_k2_tool_parser.prev_tool_call_arr = []
317
- kimi_k2_tool_parser.current_tool_id = -1
318
- kimi_k2_tool_parser.streamed_args_for_tool = []
319
-
320
- # Test with a simple tool call
321
- current_text = """ check the weather. <|tool_calls_section_begin|> <|tool_call_begin|>
322
-functions.get_weather:0 <|tool_call_argument_begin|> {"city": "Beijing"} <|tool_call_end|> <|tool_calls_section_end|>"""
323
-
324
- # First call should handle the initial setup
325
- result = kimi_k2_tool_parser.extract_tool_calls_streaming(
326
- previous_text="I'll help you",
327
- current_text=current_text,
328
- delta_text="<|tool_calls_section_end|>",
329
- previous_token_ids=[],
330
- current_token_ids=[],
331
- delta_token_ids=[],
332
- request=None,
333
- )
334
-
335
- # The result might be None or contain tool call information
336
- # This depends on the internal state management
337
- if result is not None and hasattr(result, "tool_calls") and result.tool_calls:
338
- assert len(result.tool_calls) >= 0
339
-
340
-
341
-def test_streaming_no_tool_calls(kimi_k2_tool_parser):
342
- """Test streaming when there are no tool calls."""
343
- current_text = "This is just regular text without any tool calls."
344
-
345
- result = kimi_k2_tool_parser.extract_tool_calls_streaming(
346
- previous_text="This is just regular text",
347
- current_text=current_text,
348
- delta_text=" without any tool calls.",
349
- previous_token_ids=[],
350
- current_token_ids=[],
351
- delta_token_ids=[],
352
- request=None,
353
- )
354
-
355
- # Should return the delta text as content
356
- assert result is not None
357
- assert hasattr(result, "content")
358
- assert result.content == " without any tool calls."
359
-
360
-
361
-def test_token_leak_between_section_and_tool_begin(kimi_k2_tool_parser):
362
- """
363
- Test that text between <|tool_calls_section_begin|> and <|tool_call_begin|>
364
- is suppressed and does not leak into reasoning_delta.
365
- This is the main vulnerability being fixed.
366
- """
367
- kimi_k2_tool_parser.reset_streaming_state()
368
-
369
- # Get token IDs for the markers
370
- section_begin_token_id = kimi_k2_tool_parser.vocab.get(
371
- "<|tool_calls_section_begin|>"
372
- )
373
- tool_call_begin_token_id = kimi_k2_tool_parser.vocab.get("<|tool_call_begin|>")
374
-
375
- # Simulate streaming sequence:
376
- deltas = [
377
- ("I'll help you with that. ", [1, 2, 3]),
378
- ("<|tool_calls_section_begin|>", [section_begin_token_id]),
379
- (" spurious text ", [4, 5]),
380
- ("<|tool_call_begin|>", [tool_call_begin_token_id]),
381
- ]
382
-
383
- results = run_streaming_sequence(kimi_k2_tool_parser, deltas)
384
-
385
- # Delta 1: "I'll help you with that. "
386
- assert results[0] is not None
387
- assert results[0].content == "I'll help you with that. "
388
-
389
- # Delta 2: "<|tool_calls_section_begin|>"
390
- # Section marker should be stripped and suppressed
391
- assert results[1] is None or (
392
- results[1].content is None or results[1].content == ""
393
- )
394
-
395
- # Delta 3: " spurious text or tokens " (THE LEAK SCENARIO)
396
- # CRITICAL: This text should be suppressed, NOT returned as reasoning_delta
397
- assert results[2] is None or (
398
- results[2].content is None or results[2].content == ""
399
- )
400
-
401
- # Delta 4: "<|tool_call_begin|>..."
402
- # Now we're in tool call mode, result depends on internal state
403
- # The key is that the spurious text from Delta 3 was not leaked
404
-
405
-
406
-def test_split_markers_across_deltas(kimi_k2_tool_parser):
407
- """
408
- Test that markers split across delta chunks are correctly detected
409
- via the rolling buffer mechanism.
410
- """
411
- kimi_k2_tool_parser.reset_streaming_state()
412
-
413
- section_begin_token_id = kimi_k2_tool_parser.vocab.get(
414
- "<|tool_calls_section_begin|>"
115
+ ["process_data"],
116
+ [{"name": "test", "value": 123}],
117
+ "Formatted. ",
118
+ id="multiline_json",
119
+ ),
120
+ pytest.param(
121
+ "No prefix. " + _wrap(_tool("get_weather:0", '{"city": "Tokyo"}')),
122
+ ["get_weather"],
123
+ [{"city": "Tokyo"}],
124
+ "No prefix. ",
125
+ id="no_functions_prefix",
126
+ ),
127
+ pytest.param(
128
+ "Empty args. " + _wrap(_tool("functions.test:0", "{}")),
129
+ ["test"],
130
+ [{}],
131
+ "Empty args. ",
132
+ id="empty_arguments",
133
+ ),
134
+ ],
135
)
416
-
417
- # Delta 1: partial token, Delta 2: complete marker
418
- deltas = [
419
- ("<|tool_calls_sec", [3]),
420
- ("tion_begin|> ", [section_begin_token_id, 4]),
421
- ]
422
-
423
- _results = run_streaming_sequence(kimi_k2_tool_parser, deltas)
424
-
425
- # Now the complete marker should be detected via buffer
426
- assert kimi_k2_tool_parser.in_tool_section is True
427
-
428
-
429
-def test_marker_variants(kimi_k2_tool_parser):
430
- """Test that both singular and plural marker variants are recognized."""
431
- kimi_k2_tool_parser.reset_streaming_state()
432
-
433
- # Test singular variant: <|tool_call_section_begin|> (note: singular "call")
434
- singular_token_id = kimi_k2_tool_parser.vocab.get("<|tool_call_section_begin|>")
435
-
436
- if singular_token_id is not None: # Only test if tokenizer supports it
437
- _result = kimi_k2_tool_parser.extract_tool_calls_streaming(
438
- previous_text="Reasoning ",
439
- current_text="Reasoning <|tool_call_section_begin|>",
440
- delta_text="<|tool_call_section_begin|>",
441
- previous_token_ids=[1, 2],
442
- current_token_ids=[1, 2, singular_token_id],
443
- delta_token_ids=[singular_token_id],
444
- request=None,
136
+ def test_extract_tool_calls(
137
+ self, parser, model_output, expected_names, expected_args_list, expected_content
138
+ ):
139
+ content, tool_calls = run_tool_extraction(parser, model_output, streaming=False)
140
+ assert content == expected_content
141
+ assert len(tool_calls) == len(expected_names)
142
+ for tc, name, expected_args in zip(
143
+ tool_calls, expected_names, expected_args_list
144
+ ):
145
+ assert tc.type == "function"
146
+ assert tc.function.name == name
147
+ assert json.loads(tc.function.arguments) == expected_args
148
+ # id format: "something:digit"
149
+ assert tc.id.split(":")[-1].isdigit()
150
+
151
+ def test_invalid_json_still_extracted(self, parser):
152
+ """Tool calls with invalid JSON are still returned (arguments as-is)."""
153
+ model_output = (
154
+ "Help. "
155
+ + SECTION_BEGIN
156
+ + _tool("functions.bad:0", '{"city": "Beijing"')
157
+ + _tool("functions.good:1", '{"city": "Shanghai"}')
158
+ + SECTION_END
159
)
446
- # Should enter tool section mode with singular variant too
447
- assert kimi_k2_tool_parser.in_tool_section is True
448
-
449
-
450
-def test_reentry_to_reasoning_after_tool_section(kimi_k2_tool_parser):
451
- """
452
- Test that after exiting a tool section with <|tool_calls_section_end|>,
453
- subsequent text is correctly returned as reasoning content.
454
- """
455
- kimi_k2_tool_parser.reset_streaming_state()
456
-
457
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
458
- section_end_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_end|>")
459
-
460
- deltas = [
461
- ("<|tool_calls_section_begin|>", [section_begin_id]),
462
- ("<|tool_calls_section_end|>", [section_end_id]),
463
- (" More reasoning", [10, 11]),
464
- ]
465
-
466
- results = run_streaming_sequence(kimi_k2_tool_parser, deltas)
467
-
468
- assert kimi_k2_tool_parser.in_tool_section is False
469
- assert results[2] is not None
470
- assert results[2].content == " More reasoning"
471
-
472
-
473
-def test_empty_tool_section(kimi_k2_tool_parser):
474
- """Test an empty tool section (begin immediately followed by end)."""
475
- kimi_k2_tool_parser.reset_streaming_state()
476
-
477
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
478
- section_end_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_end|>")
479
-
480
- # Section begin
481
- _result1 = kimi_k2_tool_parser.extract_tool_calls_streaming(
482
- previous_text="Reasoning ",
483
- current_text="Reasoning <|tool_calls_section_begin|>",
484
- delta_text="<|tool_calls_section_begin|>",
485
- previous_token_ids=[1],
486
- current_token_ids=[1, section_begin_id],
487
- delta_token_ids=[section_begin_id],
488
- request=None,
489
- )
490
-
491
- # Immediate section end
492
- _result2 = kimi_k2_tool_parser.extract_tool_calls_streaming(
493
- previous_text="Reasoning <|tool_calls_section_begin|>",
494
- current_text="Reasoning <|tool_calls_section_begin|><|tool_calls_section_end|>",
495
- delta_text="<|tool_calls_section_end|>",
496
- previous_token_ids=[1, section_begin_id],
497
- current_token_ids=[1, section_begin_id, section_end_id],
498
- delta_token_ids=[section_end_id],
499
- request=None,
500
- )
501
- # Should exit cleanly without errors
502
- assert kimi_k2_tool_parser.in_tool_section is False
503
-
504
-
505
-def test_malformed_tool_section_recovery(kimi_k2_tool_parser):
506
- """
507
- Test that the parser recovers from a malformed tool section
508
- that never closes properly.
509
- """
510
- kimi_k2_tool_parser.reset_streaming_state()
511
-
512
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
513
-
514
- # Enter tool section
515
- _result1 = kimi_k2_tool_parser.extract_tool_calls_streaming(
516
- previous_text="",
517
- current_text="<|tool_calls_section_begin|>",
518
- delta_text="<|tool_calls_section_begin|>",
519
- previous_token_ids=[],
520
- current_token_ids=[section_begin_id],
521
- delta_token_ids=[section_begin_id],
522
- request=None,
523
- )
524
- assert kimi_k2_tool_parser.in_tool_section is True
525
-
526
- # Simulate a lot of text without proper tool calls or section end
527
- # This should trigger the error recovery mechanism
528
- large_text = "x" * 10000 # Exceeds max_section_chars
529
-
530
- result2 = kimi_k2_tool_parser.extract_tool_calls_streaming(
531
- previous_text="<|tool_calls_section_begin|>",
532
- current_text="<|tool_calls_section_begin|>" + large_text,
533
- delta_text=large_text,
534
- previous_token_ids=[section_begin_id],
535
- current_token_ids=[section_begin_id] + list(range(100, 100 + len(large_text))),
536
- delta_token_ids=list(range(100, 100 + len(large_text))),
537
- request=None,
538
- )
539
-
540
- # Parser should have force-exited the tool section
541
- assert kimi_k2_tool_parser.in_tool_section is False
542
- # And returned the content as reasoning
543
- assert result2 is not None
544
- assert result2.content == large_text
545
-
546
-
547
-def test_state_reset(kimi_k2_tool_parser):
548
- """Test that reset_streaming_state() properly clears all state."""
549
- # Put parser in a complex state
550
- kimi_k2_tool_parser.in_tool_section = True
551
- kimi_k2_tool_parser.token_buffer = "some buffer"
552
- kimi_k2_tool_parser.current_tool_id = 5
553
- kimi_k2_tool_parser.prev_tool_call_arr = [{"id": "test"}]
554
- kimi_k2_tool_parser.section_char_count = 1000
555
-
556
- # Reset
557
- kimi_k2_tool_parser.reset_streaming_state()
558
-
559
- # Verify all state is cleared
560
- assert kimi_k2_tool_parser.in_tool_section is False
561
- assert kimi_k2_tool_parser.token_buffer == ""
562
- assert kimi_k2_tool_parser.current_tool_id == -1
563
- assert kimi_k2_tool_parser.prev_tool_call_arr == []
564
- assert kimi_k2_tool_parser.section_char_count == 0
565
- assert kimi_k2_tool_parser.current_tool_name_sent is False
566
- assert kimi_k2_tool_parser.streamed_args_for_tool == []
567
-
568
-
569
-def test_section_begin_noise_tool_begin_same_chunk(kimi_k2_tool_parser):
570
- """
571
- Test that begin→noise→tool_begin within the SAME chunk suppresses
572
- the noise text correctly (not just across chunks).
573
- """
574
- kimi_k2_tool_parser.reset_streaming_state()
575
-
576
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
577
- tool_call_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_call_begin|>")
578
-
579
- # Single delta containing: section_begin + spurious text + tool_call_begin
580
- combined_text = "<|tool_calls_section_begin|> noise text <|tool_call_begin|>"
581
-
582
- result = kimi_k2_tool_parser.extract_tool_calls_streaming(
583
- previous_text="Reasoning ",
584
- current_text="Reasoning " + combined_text,
585
- delta_text=combined_text,
586
- previous_token_ids=[1, 2],
587
- current_token_ids=[1, 2, section_begin_id, 3, 4, tool_call_begin_id],
588
- delta_token_ids=[section_begin_id, 3, 4, tool_call_begin_id],
589
- request=None,
590
- )
591
-
592
- # The noise text should NOT leak into content
593
- # Result should either be None/empty or start tool call parsing
594
- if result is not None and result.content is not None:
595
- # If content is returned, it should not contain the noise
596
- assert "noise text" not in result.content
597
- assert result.content == "" or result.content.strip() == ""
598
-
599
-
600
-def test_stream_ends_without_section_end_marker(kimi_k2_tool_parser):
601
- """
602
- Test that if the stream ends (EOF) without a proper section end marker,
603
- the parser doesn't leak text, doesn't crash, and resets state cleanly.
604
- """
605
- kimi_k2_tool_parser.reset_streaming_state()
606
-
607
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
608
-
609
- # Enter tool section
610
- _result1 = kimi_k2_tool_parser.extract_tool_calls_streaming(
611
- previous_text="",
612
- current_text="<|tool_calls_section_begin|>",
613
- delta_text="<|tool_calls_section_begin|>",
614
- previous_token_ids=[],
615
- current_token_ids=[section_begin_id],
616
- delta_token_ids=[section_begin_id],
617
- request=None,
618
- )
619
- assert kimi_k2_tool_parser.in_tool_section is True
620
-
621
- # Some content in tool section
622
- result2 = kimi_k2_tool_parser.extract_tool_calls_streaming(
623
- previous_text="<|tool_calls_section_begin|>",
624
- current_text="<|tool_calls_section_begin|> partial content",
625
- delta_text=" partial content",
626
- previous_token_ids=[section_begin_id],
627
- current_token_ids=[section_begin_id, 10, 11],
628
- delta_token_ids=[10, 11],
629
- request=None,
630
- )
631
- # Content should be suppressed
632
- assert result2.content == "" or result2.content is None
633
-
634
- # Stream ends (EOF) - no more deltas, no section_end marker
635
- # Simulate this by manually checking state and resetting
636
- # (In real usage, the request handler would call reset_streaming_state)
637
- assert kimi_k2_tool_parser.in_tool_section is True # Still in section
638
-
639
- # Reset state (as would happen between requests)
640
- kimi_k2_tool_parser.reset_streaming_state()
641
-
642
- # Verify clean slate
643
- assert kimi_k2_tool_parser.in_tool_section is False
644
- assert kimi_k2_tool_parser.token_buffer == ""
645
-
646
- # Next request should work normally
647
- result3 = kimi_k2_tool_parser.extract_tool_calls_streaming(
648
- previous_text="",
649
- current_text="New reasoning",
650
- delta_text="New reasoning",
651
- previous_token_ids=[],
652
- current_token_ids=[20, 21],
653
- delta_token_ids=[20, 21],
654
- request=None,
655
- )
656
- assert result3 is not None
657
- assert result3.content == "New reasoning"
658
-
659
-
660
-def test_same_chunk_begin_and_end_markers(kimi_k2_tool_parser):
661
- """
662
- CRITICAL TEST: Verify that when both section_begin and section_end
663
- markers appear in the SAME chunk, the parser correctly:
664
- 1. Enters the tool section
665
- 2. Immediately exits the tool section
666
- 3. Does NOT get stuck in in_tool_section=True state
667
-
668
- This tests the bug fix where elif was changed to if to handle
669
- both state transitions in a single delta.
670
- """
671
- kimi_k2_tool_parser.reset_streaming_state()
672
-
673
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
674
- section_end_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_end|>")
675
-
676
- # Single chunk with both markers (e.g., empty tool section)
677
- combined_delta = "<|tool_calls_section_begin|><|tool_calls_section_end|>"
678
-
679
- result = kimi_k2_tool_parser.extract_tool_calls_streaming(
680
- previous_text="Some reasoning ",
681
- current_text="Some reasoning " + combined_delta,
682
- delta_text=combined_delta,
683
- previous_token_ids=[1, 2],
684
- current_token_ids=[1, 2, section_begin_id, section_end_id],
685
- delta_token_ids=[section_begin_id, section_end_id],
686
- request=None,
687
- )
688
-
689
- # CRITICAL: Parser should NOT be stuck in tool section
690
- assert kimi_k2_tool_parser.in_tool_section is False, (
691
- "Parser stuck in tool section after processing both begin/end in same chunk. "
692
- "This indicates the elif bug was not fixed."
693
- )
694
-
695
- # Result should be empty or contain only stripped content
696
- assert result is not None
697
- assert result.content == "" or result.content is None
698
-
699
- # Verify subsequent content streams correctly (not suppressed)
700
- result2 = kimi_k2_tool_parser.extract_tool_calls_streaming(
701
- previous_text="Some reasoning " + combined_delta,
702
- current_text="Some reasoning " + combined_delta + " More reasoning",
703
- delta_text=" More reasoning",
704
- previous_token_ids=[1, 2, section_begin_id, section_end_id],
705
- current_token_ids=[1, 2, section_begin_id, section_end_id, 10, 11],
706
- delta_token_ids=[10, 11],
707
- request=None,
708
- )
709
-
710
- # This content should NOT be suppressed (we're out of tool section)
711
- assert result2 is not None
712
- assert result2.content == " More reasoning"
713
-
714
-
715
-def test_same_chunk_begin_content_end_markers(kimi_k2_tool_parser):
716
- """
717
- Test the same-chunk scenario with actual content between markers.
718
- Example: <|tool_calls_section_begin|> text <|tool_calls_section_end|>
719
- all arriving in one delta. The key is that the state machine correctly
720
- transitions in and out within the same chunk.
721
- """
722
- kimi_k2_tool_parser.reset_streaming_state()
723
-
724
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
725
- section_end_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_end|>")
726
-
727
- # Chunk with begin, some whitespace/noise, and end all together
728
- # This simulates a tool section that opens and closes in the same chunk
729
- combined_delta = "<|tool_calls_section_begin|> <|tool_calls_section_end|>"
730
-
731
- _result = kimi_k2_tool_parser.extract_tool_calls_streaming(
732
- previous_text="Reasoning ",
733
- current_text="Reasoning " + combined_delta,
734
- delta_text=combined_delta,
735
- previous_token_ids=[1],
736
- current_token_ids=[1, section_begin_id, 100, section_end_id],
737
- delta_token_ids=[section_begin_id, 100, section_end_id],
738
- request=None,
739
- )
740
-
741
- # Parser should exit cleanly (not stuck in tool section)
742
- assert kimi_k2_tool_parser.in_tool_section is False
743
-
744
- # Verify the fix: next content should stream normally, not be suppressed
745
- result2 = kimi_k2_tool_parser.extract_tool_calls_streaming(
746
- previous_text="Reasoning " + combined_delta,
747
- current_text="Reasoning " + combined_delta + " Done",
748
- delta_text=" Done",
749
- previous_token_ids=[1, section_begin_id, 100, section_end_id],
750
- current_token_ids=[1, section_begin_id, 100, section_end_id, 200],
751
- delta_token_ids=[200],
752
- request=None,
753
- )
754
-
755
- # Content after section should be returned (not suppressed)
756
- assert result2 is not None
757
- assert result2.content == " Done"
758
-
759
-
760
-def test_tool_call_end_and_section_end_same_chunk(kimi_k2_tool_parser):
761
- """
762
- CRITICAL TEST (P1): Verify that when both <|tool_call_end|> and
763
- <|tool_calls_section_end|> appear in the SAME chunk, the parser:
764
- 1. Processes the tool_call_end first (emits final arguments)
765
- 2. THEN exits the section
766
- 3. Does NOT drop the final tool call update
767
- 4. Does NOT leak special tokens into reasoning
768
-
769
- This tests the deferred section exit fix.
770
- """
771
- kimi_k2_tool_parser.reset_streaming_state()
772
-
773
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
774
- section_end_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_end|>")
775
- tool_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_call_begin|>")
776
- tool_end_id = kimi_k2_tool_parser.vocab.get("<|tool_call_end|>")
777
-
778
- # Simulate a streaming sequence for a SHORT tool call (all in one chunk):
779
- combined = (
780
- '<|tool_call_begin|>get_weather:0 <|tool_call_argument_begin|> {"city": "Paris"} '
781
- "<|tool_call_end|><|tool_calls_section_end|>"
782
- )
783
-
784
- deltas = [
785
- ("Let me help. ", [1, 2]),
786
- ("<|tool_calls_section_begin|>", [section_begin_id]),
787
- (combined, [tool_begin_id, 10, 11, 12, tool_end_id, section_end_id]),
788
- (" Done", [20]),
789
- ]
790
-
791
- results = run_streaming_sequence(kimi_k2_tool_parser, deltas)
792
-
793
- # CRITICAL: Parser should have exited section AFTER processing tool
794
- assert kimi_k2_tool_parser.in_tool_section is False
795
-
796
- # Tool call should have been emitted (not dropped)
797
- if results[2] is not None and results[2].content is not None:
798
- # Verify no special tokens leaked into content
799
- assert "<|tool_call_end|>" not in results[2].content
800
- assert "<|tool_calls_section_end|>" not in results[2].content
160
+ content, tool_calls = run_tool_extraction(parser, model_output, streaming=False)
161
+ assert len(tool_calls) == 2
162
+ assert tool_calls[0].function.name == "bad"
163
+ assert tool_calls[1].function.name == "good"
164
+
165
+ def test_invalid_funcall_id_skipped(self, parser):
166
+ """Tool calls with malformed id (no colon+digit) are skipped."""
167
+ model_output = (
168
+ "Help. "
169
+ + SECTION_BEGIN
170
+ + _tool("functions.invalid.0", '{"city": "Beijing"}')
171
+ + _tool("functions.valid:1", '{"city": "Shanghai"}')
172
+ + SECTION_END
173
+ )
174
+ content, tool_calls = run_tool_extraction(parser, model_output, streaming=False)
175
+ assert len(tool_calls) == 1
176
+ assert tool_calls[0].function.name == "valid"
177
+
178
+ def test_native_id_extracted(self, parser):
179
+ """Regression: parser extracts native ID onto ToolCall (PR #32768)."""
180
+ model_output = "Checking weather. " + _wrap(
181
+ _tool("functions.get_weather:0", '{"city": "Tokyo"}')
182
+ )
183
+ content, tool_calls = run_tool_extraction(parser, model_output, streaming=False)
184
+ assert len(tool_calls) == 1
185
+ assert tool_calls[0].id == "functions.get_weather:0"
186
+ assert tool_calls[0].function.name == "get_weather"
187
+ assert json.loads(tool_calls[0].function.arguments) == {"city": "Tokyo"}
188
+
189
+ def test_multi_turn_native_id_continuity(self, kimi_k2_tokenizer):
190
+ """Regression: native IDs from turn 1 preserved across turns (PR #32768)."""
191
+ turn1_parser = KimiK2ToolParser(kimi_k2_tokenizer)
192
+ turn1_output = "Let me check. " + _wrap(
193
+ _tool("functions.get_weather:0", '{"city": "Beijing"}')
194
+ )
195
+ _, turn1_tools = run_tool_extraction(
196
+ turn1_parser, turn1_output, streaming=False
197
+ )
198
+ assert len(turn1_tools) == 1
199
+ assert turn1_tools[0].id == "functions.get_weather:0"
200
802
- # Content after tool section should stream normally
803
- assert results[3] is not None
804
- assert results[3].content == " Done"
201
+ # Fresh parser for turn 2
202
+ turn2_parser = KimiK2ToolParser(kimi_k2_tokenizer)
203
+ turn2_output = "Now let me get news. " + _wrap(
204
+ _tool("functions.get_news:0", '{"topic": "weather in Beijing"}')
205
+ )
206
+ _, turn2_tools = run_tool_extraction(
207
+ turn2_parser, turn2_output, streaming=False
208
+ )
209
+ assert len(turn2_tools) == 1
210
+ assert turn2_tools[0].id == "functions.get_news:0"
211
212
807
-def test_streaming_tool_call_markers_not_leaked(kimi_k2_tool_parser):
808
- """
809
- CRITICAL TEST: Verify that tool call markers (<|tool_call_begin|>,
810
- <|tool_call_end|>, <|tool_call_argument_begin|>) are NOT leaked
811
- into the content field during streaming.
213
+def _split_tool_output_to_deltas(
214
+ content: str, tool_strs: list[tuple[str, str]]
215
+) -> list[str]:
216
+ """Build a list of string deltas with special tokens as separate chunks.
217
813
- This reproduces the AWS Bedrock bug where tool call markers appeared
814
- in the 'text' field of responses.
218
+ Args:
219
+ content: text before tool section
220
+ tool_strs: list of (tool_id, args_json)
221
"""
816
- kimi_k2_tool_parser.reset_streaming_state()
817
-
818
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
819
- section_end_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_end|>")
820
- tool_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_call_begin|>")
821
- tool_end_id = kimi_k2_tool_parser.vocab.get("<|tool_call_end|>")
822
-
823
- # List of markers that should NEVER appear in content
824
- forbidden_markers = [
825
- "<|tool_call_begin|>",
826
- "<|tool_call_end|>",
827
- "<|tool_call_argument_begin|>",
828
- "<|tool_calls_section_begin|>",
829
- "<|tool_calls_section_end|>",
830
- ]
831
-
832
- all_content = []
833
-
834
- # Steps: reasoning, section begin, tool call, section end, more reasoning
835
- tool_chunk = (
836
- "<|tool_call_begin|> functions.get_weather:0 "
837
- '<|tool_call_argument_begin|> {"city": "Tokyo"} <|tool_call_end|>'
838
- )
839
- deltas = [
840
- ("I'll check the weather. ", [1, 2, 3]),
841
- ("<|tool_calls_section_begin|>", [section_begin_id]),
842
- (tool_chunk, [tool_begin_id, 10, 11, tool_end_id]),
843
- ("<|tool_calls_section_end|>", [section_end_id]),
844
- (" Here's the result.", [20, 21]),
845
- ]
846
-
847
- results = run_streaming_sequence(kimi_k2_tool_parser, deltas)
848
-
849
- for res in results:
850
- if res and res.content:
851
- all_content.append(res.content)
852
-
853
- # CRITICAL ASSERTIONS: No forbidden markers in any content
854
- full_content = "".join(all_content)
855
- for marker in forbidden_markers:
856
- assert marker not in full_content, (
857
- f"MARKER LEAK DETECTED: '{marker}' found in content. "
858
- f"Full content: {repr(full_content)}"
222
+ deltas = [content, SECTION_BEGIN]
223
+ for tool_id, args_json in tool_strs:
224
+ deltas.extend(
225
+ [
226
+ TOOL_BEGIN,
227
+ f"{tool_id} ",
228
+ ARG_BEGIN,
229
+ f"{args_json} ",
230
+ TOOL_END,
231
+ ]
232
)
860
-
861
- # Also check that tool call content (function name, arguments) is not leaked
862
- assert "get_weather" not in full_content, (
863
- f"TOOL CALL CONTENT LEAKED: 'get_weather' found in content. "
864
- f"Full content: {repr(full_content)}"
865
- )
866
- assert "Tokyo" not in full_content, (
867
- f"TOOL CALL CONTENT LEAKED: 'Tokyo' found in content. "
868
- f"Full content: {repr(full_content)}"
869
- )
870
-
871
- # Verify that legitimate content was preserved
872
- assert "I'll check the weather." in full_content or len(all_content) > 0
233
+ deltas.append(SECTION_END)
234
+ return deltas
235
236
875
-def test_native_id_extracted_and_placed_on_tool_call(kimi_k2_tool_parser):
876
- """Regression: parser extracts native ID onto ToolCall (PR #32768)."""
877
- model_output = (
878
- "Checking weather. "
879
- "<|tool_calls_section_begin|>"
880
- "<|tool_call_begin|>functions.get_weather:0"
881
- '<|tool_call_argument_begin|>{"city": "Tokyo"}'
882
- "<|tool_call_end|>"
883
- "<|tool_calls_section_end|>"
884
- )
885
-
886
- result = kimi_k2_tool_parser.extract_tool_calls(model_output, request=None)
887
- assert result.tools_called
888
- assert len(result.tool_calls) == 1
889
-
890
- tc = result.tool_calls[0]
891
- # Native ID from model output must be used as the tool call ID
892
- assert tc.id == "functions.get_weather:0"
893
- assert tc.function.name == "get_weather"
894
- assert json.loads(tc.function.arguments) == {"city": "Tokyo"}
895
-
896
-
897
-def test_multi_turn_native_id_continuity(kimi_k2_tool_parser, kimi_k2_tokenizer):
898
- """Regression: native IDs from turn 1 preserved across turns (PR #32768)."""
899
- turn1_output = (
900
- "Let me check. "
901
- "<|tool_calls_section_begin|>"
902
- "<|tool_call_begin|>functions.get_weather:0"
903
- '<|tool_call_argument_begin|>{"city": "Beijing"}'
904
- "<|tool_call_end|>"
905
- "<|tool_calls_section_end|>"
237
+class TestStreamingHappyPath:
238
+ def test_single_tool_call(self, parser):
239
+ """Verify DeltaToolCall output: name, id, arguments for one tool."""
240
+ deltas = _split_tool_output_to_deltas(
241
+ "I'll help. ",
242
+ [("functions.get_weather:0", '{"city": "Beijing"}')],
243
+ )
244
+ rec = run_tool_extraction_streaming(parser, deltas)
245
+
246
+ assert len(rec.tool_calls) == 1
247
+ tc = rec.tool_calls[0]
248
+ assert tc.function.name == "get_weather"
249
+ assert tc.id == "functions.get_weather:0"
250
+ assert json.loads(tc.function.arguments) == {"city": "Beijing"}
251
+
252
+ def test_multiple_tool_calls(self, parser):
253
+ """Two tool calls emitted with correct indices, names, arguments."""
254
+ deltas = _split_tool_output_to_deltas(
255
+ "Compare weather. ",
256
+ [
257
+ ("functions.get_weather:0", '{"city": "Tokyo"}'),
258
+ ("functions.get_weather:1", '{"city": "NYC"}'),
259
+ ],
260
+ )
261
+ rec = run_tool_extraction_streaming(parser, deltas)
262
+
263
+ assert len(rec.tool_calls) == 2
264
+ assert rec.tool_calls[0].function.name == "get_weather"
265
+ assert rec.tool_calls[0].id == "functions.get_weather:0"
266
+ assert json.loads(rec.tool_calls[0].function.arguments) == {"city": "Tokyo"}
267
+
268
+ assert rec.tool_calls[1].function.name == "get_weather"
269
+ assert rec.tool_calls[1].id == "functions.get_weather:1"
270
+ assert json.loads(rec.tool_calls[1].function.arguments) == {"city": "NYC"}
271
+
272
+ def test_content_before_tools(self, parser):
273
+ """Content before section is streamed; markers/args don't leak."""
274
+ deltas = _split_tool_output_to_deltas(
275
+ "I'll check the weather. ",
276
+ [("functions.get_weather:0", '{"city": "Tokyo"}')],
277
+ )
278
+ rec = run_tool_extraction_streaming(parser, deltas)
279
+
280
+ assert "check the weather" in rec.other_content
281
+ # No markers or tool content leaked
282
+ for marker in [SECTION_BEGIN, SECTION_END, TOOL_BEGIN, TOOL_END, ARG_BEGIN]:
283
+ assert marker not in rec.other_content
284
+ assert "get_weather" not in rec.other_content
285
+ assert "Tokyo" not in rec.other_content
286
+
287
+ def test_no_tool_calls(self, parser):
288
+ """Plain text streaming returns content only."""
289
+ deltas = ["This is just ", "regular text ", "without tools."]
290
+ rec = run_tool_extraction_streaming(parser, deltas)
291
+
292
+ assert rec.other_content == "This is just regular text without tools."
293
+ assert rec.tool_calls == []
294
+
295
+ def test_incremental_arguments(self, parser):
296
+ """Arguments split across small chunks accumulate correctly."""
297
+ deltas = [
298
+ "Help. ",
299
+ SECTION_BEGIN,
300
+ TOOL_BEGIN,
301
+ "functions.get_weather:0 ",
302
+ ARG_BEGIN,
303
+ '{"ci',
304
+ 'ty": "Be',
305
+ 'ijing"}',
306
+ " ",
307
+ TOOL_END,
308
+ SECTION_END,
309
+ ]
310
+ rec = run_tool_extraction_streaming(parser, deltas)
311
+
312
+ assert len(rec.tool_calls) == 1
313
+ assert rec.tool_calls[0].function.name == "get_weather"
314
+ assert json.loads(rec.tool_calls[0].function.arguments) == {"city": "Beijing"}
315
+
316
+ @pytest.mark.parametrize(
317
+ "model_output",
318
+ [
319
+ pytest.param(
320
+ "Single. "
321
+ + _wrap(_tool("functions.get_weather:0", '{"city": "Beijing"}')),
322
+ id="single_tool",
323
+ ),
324
+ pytest.param(
325
+ "Multi. "
326
+ + _wrap(
327
+ _tool("functions.get_weather:0", '{"city": "Tokyo"}'),
328
+ _tool("functions.get_news:1", '{"topic": "tech"}'),
329
+ ),
330
+ id="parallel_tools",
331
+ ),
332
+ pytest.param(
333
+ "No prefix id. " + _wrap(_tool("get_weather:0", '{"city": "NYC"}')),
334
+ id="no_functions_prefix",
335
+ ),
336
+ ],
337
)
338
+ def test_streaming_matches_nonstreaming(self, parser, model_output):
339
+ """Streaming reconstruction matches non-streaming extraction."""
340
+ content_non, tools_non = run_tool_extraction(
341
+ parser, model_output, streaming=False
342
+ )
343
+ content_stream, tools_stream = run_tool_extraction(
344
+ parser, model_output, streaming=True
345
+ )
346
908
- turn1_result = kimi_k2_tool_parser.extract_tool_calls(turn1_output, request=None)
909
- assert turn1_result.tools_called
910
- assert turn1_result.tool_calls[0].id == "functions.get_weather:0"
911
-
912
- # Fresh parser for turn 2
913
- turn2_parser = KimiK2ToolParser(kimi_k2_tokenizer)
914
- turn2_output = (
915
- "Now let me get news. "
916
- "<|tool_calls_section_begin|>"
917
- "<|tool_call_begin|>functions.get_news:0"
918
- '<|tool_call_argument_begin|>{"topic": "weather in Beijing"}'
919
- "<|tool_call_end|>"
920
- "<|tool_calls_section_end|>"
921
- )
347
+ assert len(tools_non) == len(tools_stream)
348
+ for tc_non, tc_stream in zip(tools_non, tools_stream):
349
+ assert tc_non.function.name == tc_stream.function.name
350
+ assert json.loads(tc_non.function.arguments) == json.loads(
351
+ tc_stream.function.arguments
352
+ )
353
923
- turn2_result = turn2_parser.extract_tool_calls(turn2_output, request=None)
924
- assert turn2_result.tools_called
925
- assert turn2_result.tool_calls[0].id == "functions.get_news:0"
354
355
+class TestStreamingEdgeCases:
356
+ def test_marker_suppression(self, parser):
357
+ """No special-token markers appear in reconstructed content."""
358
+ deltas = _split_tool_output_to_deltas(
359
+ "I'll check. ",
360
+ [("functions.get_weather:0", '{"city": "Tokyo"}')],
361
+ )
362
+ rec = run_tool_extraction_streaming(parser, deltas)
363
+
364
+ forbidden = [SECTION_BEGIN, SECTION_END, TOOL_BEGIN, TOOL_END, ARG_BEGIN]
365
+ for marker in forbidden:
366
+ assert marker not in rec.other_content, (
367
+ f"Marker leaked: {marker!r} in {rec.other_content!r}"
368
+ )
369
+
370
+ def test_noise_between_markers_suppressed(self, parser):
371
+ """Text between section_begin and tool_call_begin doesn't leak."""
372
+ deltas = [
373
+ "Reasoning. ",
374
+ SECTION_BEGIN,
375
+ " spurious noise ",
376
+ TOOL_BEGIN,
377
+ "functions.test:0 ",
378
+ ARG_BEGIN,
379
+ '{"k": "v"} ',
380
+ TOOL_END,
381
+ SECTION_END,
382
+ ]
383
+ rec = run_tool_extraction_streaming(parser, deltas)
384
+
385
+ assert "spurious" not in rec.other_content
386
+ assert "noise" not in rec.other_content
387
+
388
+ def test_empty_tool_section(self, parser):
389
+ """Empty section (begin immediately followed by end) doesn't crash."""
390
+ deltas = ["Reasoning. ", SECTION_BEGIN, SECTION_END]
391
+ rec = run_tool_extraction_streaming(parser, deltas)
392
+
393
+ assert rec.tool_calls == []
394
+
395
+ def test_three_different_tools(self, parser):
396
+ """Three tool calls with different functions stream correctly."""
397
+ deltas = _split_tool_output_to_deltas(
398
+ "Multiple tasks. ",
399
+ [
400
+ ("functions.get_weather:0", '{"city": "NYC"}'),
401
+ ("functions.get_news:1", '{"topic": "tech"}'),
402
+ ("functions.send_email:2", '{"to": "a@b.com"}'),
403
+ ],
404
+ )
405
+ rec = run_tool_extraction_streaming(parser, deltas)
406
+
407
+ assert len(rec.tool_calls) == 3
408
+ names = [tc.function.name for tc in rec.tool_calls]
409
+ assert names == ["get_weather", "get_news", "send_email"]
410
+ ids = [tc.id for tc in rec.tool_calls]
411
+ assert len(set(ids)) == 3 # unique ids
412
+
413
+ def test_truncated_tool_call_no_end_marker(self, parser):
414
+ """Stream ending mid-tool-call (max_tokens) doesn't crash."""
415
+ deltas = [
416
+ "I'll check. ",
417
+ SECTION_BEGIN,
418
+ TOOL_BEGIN,
419
+ "functions.get_weather:0 ",
420
+ ARG_BEGIN,
421
+ '{"city": "Bei',
422
+ # Stream ends here — no TOOL_END, no SECTION_END
423
+ ]
424
+ rec = run_tool_extraction_streaming(parser, deltas)
425
+
426
+ # Should not crash; tool name and partial args extracted
427
+ assert len(rec.tool_calls) == 1
428
+ assert rec.tool_calls[0].function.name == "get_weather"
429
+ assert rec.tool_calls[0].id == "functions.get_weather:0"
430
+ assert rec.tool_calls[0].function.arguments == '{"city": "Bei'
431
+ # No markers leaked into content
432
+ for marker in [SECTION_BEGIN, SECTION_END, TOOL_BEGIN, TOOL_END, ARG_BEGIN]:
433
+ assert marker not in rec.other_content
434
+
435
+ def test_content_after_tool_section(self, parser):
436
+ """Trailing text after section_end doesn't crash or leak markers."""
437
+ deltas = [
438
+ "Before. ",
439
+ SECTION_BEGIN,
440
+ TOOL_BEGIN,
441
+ "functions.get_weather:0 ",
442
+ ARG_BEGIN,
443
+ '{"city": "Tokyo"} ',
444
+ TOOL_END,
445
+ SECTION_END,
446
+ " After tools.",
447
+ ]
448
+ rec = run_tool_extraction_streaming(parser, deltas)
449
+
450
+ # Tool call extracted correctly
451
+ assert len(rec.tool_calls) == 1
452
+ assert rec.tool_calls[0].function.name == "get_weather"
453
+ assert json.loads(rec.tool_calls[0].function.arguments) == {"city": "Tokyo"}
454
+ # Trailing content after tool section is dropped
455
+ assert "After tools." not in rec.other_content
456
+ # No markers leaked into content
457
+ for marker in [SECTION_BEGIN, SECTION_END, TOOL_BEGIN, TOOL_END, ARG_BEGIN]:
458
+ assert marker not in rec.other_content
459
+
460
+
461
+class TestAdjustRequest:
462
+ def test_sets_skip_special_tokens_false(self, parser):
463
+ request = MagicMock(spec=ChatCompletionRequest)
464
+ request.tools = [{"type": "function", "function": {"name": "test"}}]
465
+ request.tool_choice = "auto"
466
+ request.skip_special_tokens = True
467
+
468
+ result = parser.adjust_request(request)
469
+ assert result.skip_special_tokens is False
470
+
471
+ def test_no_change_when_tool_choice_none(self, parser):
472
+ request = MagicMock(spec=ChatCompletionRequest)
473
+ request.tools = [{"type": "function", "function": {"name": "test"}}]
474
+ request.tool_choice = "none"
475
+ request.skip_special_tokens = True
476
+
477
+ result = parser.adjust_request(request)
478
+ assert result.skip_special_tokens is True
479
+
480
+ def test_no_change_when_no_tools(self, parser):
481
+ request = MagicMock(spec=ChatCompletionRequest)
482
+ request.tools = None
483
+ request.tool_choice = "auto"
484
+ request.skip_special_tokens = True
485
+
486
+ result = parser.adjust_request(request)
487
+ assert result.skip_special_tokens is True
488
+
489
+
490
+def _chunk_tokenized_deltas(tokenizer, text: str, stream_interval: int) -> list[str]:
491
+ """Encode text, group tokens into chunks of stream_interval, decode each."""
492
+ token_ids = tokenizer.encode(text, add_special_tokens=False)
493
+ deltas = []
494
+ prev = ""
495
+ for i in range(0, len(token_ids), stream_interval):
496
+ decoded = tokenizer.decode(
497
+ token_ids[: i + stream_interval], skip_special_tokens=False
498
+ )
499
+ deltas.append(decoded[len(prev) :])
500
+ prev = decoded
501
+ return deltas
502
928
-def test_streaming_multiple_tool_calls_not_leaked(kimi_k2_tool_parser):
929
- """
930
- Test that MULTIPLE tool calls in streaming mode do not leak into content.
931
- This reproduces the AWS Bedrock scenario: "Compare weather in Tokyo and NYC".
932
- """
933
- kimi_k2_tool_parser.reset_streaming_state()
503
935
- section_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_begin|>")
936
- section_end_id = kimi_k2_tool_parser.vocab.get("<|tool_calls_section_end|>")
937
- tool_begin_id = kimi_k2_tool_parser.vocab.get("<|tool_call_begin|>")
938
- tool_end_id = kimi_k2_tool_parser.vocab.get("<|tool_call_end|>")
504
+class TestStreamingIntervals:
505
+ """Test streaming at various token-chunk sizes to catch boundary bugs."""
506
940
- all_content = []
507
+ @pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8])
508
+ def test_single_tool_call_at_interval(self, kimi_k2_tokenizer, stream_interval):
509
+ text = "Help. " + _wrap(_tool("functions.get_weather:0", '{"city": "Beijing"}'))
510
+ deltas = _chunk_tokenized_deltas(kimi_k2_tokenizer, text, stream_interval)
511
+ parser = KimiK2ToolParser(kimi_k2_tokenizer)
512
+ rec = run_tool_extraction_streaming(
513
+ parser, deltas, assert_one_tool_per_delta=False
514
+ )
515
942
- tool1 = '<|tool_call_begin|> get_weather:0 <|tool_call_argument_begin|> {"city": "Tokyo"} <|tool_call_end|>'
943
- tool2 = ' <|tool_call_begin|> get_weather:1 <|tool_call_argument_begin|> {"city": "New York"} <|tool_call_end|>'
516
+ assert len(rec.tool_calls) == 1
517
+ assert rec.tool_calls[0].function.name == "get_weather"
518
+ assert json.loads(rec.tool_calls[0].function.arguments) == {"city": "Beijing"}
519
945
- deltas = [
946
- ("I'll compare the weather. ", [1, 2, 3]),
947
- ("<|tool_calls_section_begin|>", [section_begin_id]),
948
- (tool1, [tool_begin_id, 10, tool_end_id]),
949
- (tool2, [tool_begin_id, 20, tool_end_id]),
950
- ("<|tool_calls_section_end|>", [section_end_id]),
951
- (" Here's the comparison.", [30]),
952
- ]
520
+ @pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8])
521
+ def test_content_then_tool_call_at_interval(
522
+ self, kimi_k2_tokenizer, stream_interval
523
+ ):
524
+ text = "Sure, let me check. " + _wrap(
525
+ _tool("functions.get_weather:0", '{"city": "Tokyo"}')
526
+ )
527
+ deltas = _chunk_tokenized_deltas(kimi_k2_tokenizer, text, stream_interval)
528
+ parser = KimiK2ToolParser(kimi_k2_tokenizer)
529
+ rec = run_tool_extraction_streaming(
530
+ parser, deltas, assert_one_tool_per_delta=False
531
+ )
532
954
- results = run_streaming_sequence(kimi_k2_tool_parser, deltas)
533
+ assert "let me check" in rec.other_content
534
+ assert "get_weather" not in rec.other_content
535
+ assert len(rec.tool_calls) == 1
536
+ assert rec.tool_calls[0].function.name == "get_weather"
537
+ assert json.loads(rec.tool_calls[0].function.arguments) == {"city": "Tokyo"}
538
+
539
+ @pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8])
540
+ def test_multiple_tool_calls_at_interval(self, kimi_k2_tokenizer, stream_interval):
541
+ text = "Compare. " + _wrap(
542
+ _tool("functions.search:0", '{"q": "cats"}'),
543
+ _tool("functions.search:1", '{"q": "dogs"}'),
544
+ )
545
+ deltas = _chunk_tokenized_deltas(kimi_k2_tokenizer, text, stream_interval)
546
+ parser = KimiK2ToolParser(kimi_k2_tokenizer)
547
+ rec = run_tool_extraction_streaming(
548
+ parser, deltas, assert_one_tool_per_delta=False
549
+ )
550
956
- for res in results:
957
- if res and res.content:
958
- all_content.append(res.content)
551
+ assert len(rec.tool_calls) == 2
552
+ assert rec.tool_calls[0].function.name == "search"
553
+ assert json.loads(rec.tool_calls[0].function.arguments) == {"q": "cats"}
554
+ assert rec.tool_calls[1].function.name == "search"
555
+ assert json.loads(rec.tool_calls[1].function.arguments) == {"q": "dogs"}
556
+
557
+ @pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8])
558
+ def test_plain_text_at_interval(self, kimi_k2_tokenizer, stream_interval):
559
+ text = "This is plain text with no tool calling involved."
560
+ deltas = _chunk_tokenized_deltas(kimi_k2_tokenizer, text, stream_interval)
561
+ parser = KimiK2ToolParser(kimi_k2_tokenizer)
562
+ rec = run_tool_extraction_streaming(
563
+ parser, deltas, assert_one_tool_per_delta=False
564
+ )
565
960
- # Assertions
961
- full_content = "".join(all_content)
566
+ assert rec.other_content == text
567
+ assert rec.tool_calls == []
568
963
- # Check no markers leaked
964
- forbidden = ["<|tool_call", "<|tool_calls_section"]
965
- for marker in forbidden:
966
- assert marker not in full_content, (
967
- f"MARKER LEAKED: {marker} in {repr(full_content)}"
569
+ def test_content_and_tool_call_in_single_chunk(self, kimi_k2_tokenizer):
570
+ """Content + complete tool call in one chunk must both be emitted."""
571
+ text = "Hi! " + _wrap(_tool("functions.get_weather:0", '{"city": "Beijing"}'))
572
+ deltas = _chunk_tokenized_deltas(kimi_k2_tokenizer, text, stream_interval=9999)
573
+ parser = KimiK2ToolParser(kimi_k2_tokenizer)
574
+ rec = run_tool_extraction_streaming(
575
+ parser, deltas, assert_one_tool_per_delta=False
576
)
577
970
- # Check no tool call content leaked (both tools)
971
- assert "get_weather" not in full_content, f"TOOL NAME LEAKED: {repr(full_content)}"
972
- assert "Tokyo" not in full_content, f"TOOL ARG LEAKED (Tokyo): {repr(full_content)}"
973
- assert "New York" not in full_content, (
974
- f"TOOL ARG LEAKED (NYC): {repr(full_content)}"
975
- )
976
-
977
- # Legitimate content preserved
978
- assert "compare" in full_content.lower() or len(all_content) > 0
578
+ assert "Hi!" in rec.other_content
579
+ assert "get_weather" not in rec.other_content
580
+ assert len(rec.tool_calls) == 1
581
+ assert rec.tool_calls[0].function.name == "get_weather"
582
+ assert json.loads(rec.tool_calls[0].function.arguments) == {"city": "Beijing"}
vllm/tool_parsers/kimi_k2_tool_parser.py
+159
-484
@@ -1,6 +1,5 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-# code modified from deepseekv3_tool_parser.py
3
4
from collections.abc import Sequence
5
@@ -17,12 +16,14 @@ from vllm.entrypoints.openai.engine.protocol import (
16
FunctionCall,
17
ToolCall,
18
)
19
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
20
from vllm.logger import init_logger
21
from vllm.tokenizers import TokenizerLike
22
from vllm.tool_parsers.abstract_tool_parser import (
23
Tool,
24
ToolParser,
25
)
26
+from vllm.tool_parsers.utils import partial_tag_overlap
27
28
logger = init_logger(__name__)
29
@@ -30,124 +31,44 @@ logger = init_logger(__name__)
31
class KimiK2ToolParser(ToolParser):
32
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
33
super().__init__(tokenizer, tools)
33
- self.current_tool_name_sent: bool = False
34
+
35
+ # Streaming state
36
+ self._sent_content_idx: int = 0
37
self.prev_tool_call_arr: list[dict] = []
35
- self.current_tool_id: int = -1
36
- self.streamed_args_for_tool: list[
37
- str
38
- ] = [] # map what has been streamed for each tool so far to a list
39
-
40
- # Section-level state management to prevent token leakage
41
- self.in_tool_section: bool = False
42
- self.token_buffer: str = ""
43
- # Buffer size: empirical worst-case for longest marker (~30 chars) * 2
44
- # + safety margin for unicode + partial overlap. Prevents unbounded growth.
45
- self.buffer_max_size: int = 1024
46
- self.section_char_count: int = 0 # Track characters processed in tool section
47
- self.max_section_chars: int = 8192 # Force exit if section exceeds this
48
- self._buffer_overflow_logged: bool = False # Log overflow once per session
49
-
50
- # Support both singular and plural variants
38
+ self.streamed_args_for_tool: list[str] = []
39
+
40
+ # Section marker
41
self.tool_calls_start_token: str = "<|tool_calls_section_begin|>"
52
- self.tool_calls_end_token: str = "<|tool_calls_section_end|>"
53
- self.tool_calls_start_token_variants: list[str] = [
54
- "<|tool_calls_section_begin|>",
55
- "<|tool_call_section_begin|>", # singular variant
56
- ]
57
- self.tool_calls_end_token_variants: list[str] = [
58
- "<|tool_calls_section_end|>",
59
- "<|tool_call_section_end|>", # singular variant
60
- ]
42
43
+ # Individual tool call markers
44
self.tool_call_start_token: str = "<|tool_call_begin|>"
45
self.tool_call_end_token: str = "<|tool_call_end|>"
46
+ self.tool_call_arg_token: str = "<|tool_call_argument_begin|>"
47
48
+ # Regex for non-streaming extraction
49
self.tool_call_regex = re.compile(
66
- r"<\|tool_call_begin\|>\s*(?P<tool_call_id>[^<]+:\d+)\s*<\|tool_call_argument_begin\|>\s*(?P<function_arguments>(?:(?!<\|tool_call_begin\|>).)*?)\s*<\|tool_call_end\|>",
50
+ r"<\|tool_call_begin\|>\s*(?P<tool_call_id>[^<]+:\d+)\s*"
51
+ r"<\|tool_call_argument_begin\|>\s*"
52
+ r"(?P<function_arguments>(?:(?!<\|tool_call_begin\|>).)*?)\s*"
53
+ r"<\|tool_call_end\|>",
54
re.DOTALL,
55
)
56
70
- self.stream_tool_call_portion_regex = re.compile(
71
- r"(?P<tool_call_id>.+:\d+)\s*<\|tool_call_argument_begin\|>\s*(?P<function_arguments>.*)"
72
- )
73
-
74
- self.stream_tool_call_name_regex = re.compile(r"(?P<tool_call_id>.+:\d+)\s*")
75
-
57
if not self.model_tokenizer:
58
raise ValueError(
59
"The model tokenizer must be passed to the ToolParser "
60
"constructor during construction."
61
)
81
- self.tool_calls_start_token_id = self.vocab.get(self.tool_calls_start_token)
82
- self.tool_calls_end_token_id = self.vocab.get(self.tool_calls_end_token)
83
-
84
- # Get token IDs for all variants
85
- self.tool_calls_start_token_ids: list[int] = [
86
- tid
87
- for variant in self.tool_calls_start_token_variants
88
- if (tid := self.vocab.get(variant)) is not None
89
- ]
90
- self.tool_calls_end_token_ids: list[int] = [
91
- tid
92
- for variant in self.tool_calls_end_token_variants
93
- if (tid := self.vocab.get(variant)) is not None
94
- ]
95
-
96
- self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
97
- self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
98
-
99
- if (
100
- self.tool_calls_start_token_id is None
101
- or self.tool_calls_end_token_id is None
102
- ):
103
- raise RuntimeError(
104
- "Kimi-K2 Tool parser could not locate tool call start/end "
105
- "tokens in the tokenizer!"
106
- )
62
108
- def _check_and_strip_markers(self, text: str) -> tuple[str, bool, bool]:
109
- """
110
- Check for section begin/end markers in text and strip them.
111
- Returns: (cleaned_text, found_section_begin, found_section_end)
112
- """
113
- found_begin = False
114
- found_end = False
115
- cleaned = text
116
-
117
- # Check for section begin markers (any variant)
118
- for variant in self.tool_calls_start_token_variants:
119
- if variant in cleaned:
120
- cleaned = cleaned.replace(variant, "")
121
- found_begin = True
122
-
123
- # Check for section end markers (any variant)
124
- for variant in self.tool_calls_end_token_variants:
125
- if variant in cleaned:
126
- cleaned = cleaned.replace(variant, "")
127
- found_end = True
128
- return cleaned, found_begin, found_end
129
-
130
- def _reset_section_state(self) -> None:
131
- """Reset state when exiting tool section."""
132
- self.in_tool_section = False
133
- self.token_buffer = ""
134
- self.section_char_count = 0
135
-
136
- def reset_streaming_state(self) -> None:
137
- """
138
- Reset all streaming state. Call this between requests to prevent
139
- state leakage when parser instance is reused.
140
- """
141
- # Reset section state
142
- self._reset_section_state()
143
-
144
- # Reset parent class state
145
- self.current_tool_name_sent = False
146
- self.prev_tool_call_arr = []
147
- self.current_tool_id = -1
148
- self.streamed_args_for_tool = []
149
-
150
- logger.debug("Streaming state reset")
63
+ def adjust_request(
64
+ self, request: ChatCompletionRequest | ResponsesRequest
65
+ ) -> ChatCompletionRequest | ResponsesRequest:
66
+ request = super().adjust_request(request)
67
+ if request.tools and request.tool_choice != "none":
68
+ # Ensure special-token markers appear as literal text in
69
+ # current_text so we can do pure text-based parsing.
70
+ request.skip_special_tokens = False
71
+ return request
72
73
def extract_tool_calls(
74
self,
@@ -198,6 +119,95 @@ class KimiK2ToolParser(ToolParser):
119
tools_called=False, tool_calls=[], content=model_output
120
)
121
122
+ def _extract_content(self, current_text: str) -> str | None:
123
+ """Return unsent content before the tool-calls section, or None.
124
+
125
+ Holds back any trailing suffix that partially matches
126
+ ``<|tool_calls_section_begin|>`` to avoid leaking marker bytes.
127
+ """
128
+ if self.tool_calls_start_token not in current_text:
129
+ overlap = partial_tag_overlap(current_text, self.tool_calls_start_token)
130
+ sendable_idx = len(current_text) - overlap
131
+ else:
132
+ sendable_idx = current_text.index(self.tool_calls_start_token)
133
+
134
+ if sendable_idx > self._sent_content_idx:
135
+ content = current_text[self._sent_content_idx : sendable_idx]
136
+ self._sent_content_idx = sendable_idx
137
+ return content
138
+ return None
139
+
140
+ def _extract_tool_calls(self, current_text: str) -> list[str]:
141
+ """Extract raw bodies from ``<|tool_call_begin|>…<|tool_call_end|>`` blocks."""
142
+ if self.tool_calls_start_token not in current_text:
143
+ return []
144
+
145
+ results: list[str] = []
146
+ pos = current_text.index(self.tool_calls_start_token)
147
+ while True:
148
+ start = current_text.find(self.tool_call_start_token, pos)
149
+ if start == -1:
150
+ break
151
+ tc_start = start + len(self.tool_call_start_token)
152
+ end = current_text.find(self.tool_call_end_token, tc_start)
153
+
154
+ if end != -1:
155
+ tool_call = current_text[tc_start:end]
156
+ pos = end + len(self.tool_call_end_token)
157
+ else:
158
+ tool_call = current_text[tc_start:]
159
+ overlap = partial_tag_overlap(tool_call, self.tool_call_end_token)
160
+ if overlap:
161
+ tool_call = tool_call[:-overlap]
162
+
163
+ results.append(tool_call)
164
+
165
+ if end == -1:
166
+ break
167
+ return results
168
+
169
+ @staticmethod
170
+ def _extract_tool_id_and_name(
171
+ header: str | None,
172
+ ) -> tuple[str | None, str | None]:
173
+ """Parse ``(tool_id, tool_name)`` from a header
174
+ like ``"functions.get_weather:0"``."""
175
+ if header is None:
176
+ return None, None
177
+ match = re.match(r"(.+:\d+)", header)
178
+ if not match:
179
+ return None, None
180
+
181
+ tool_id = match.group(1).strip()
182
+ tool_name = tool_id.split(":")[0].split(".")[-1]
183
+ return tool_id, tool_name
184
+
185
+ def _split_tool_call(self, tool_call: str) -> tuple[str | None, str | None]:
186
+ """Split a tool-call body into ``(header, arguments)`` at the argument marker.
187
+
188
+ Example::
189
+ 'get_weather:0 <|tool_call_argument_begin|>{"c'
190
+ -> ("get_weather:0", '{"c')
191
+ """
192
+ arg_pos = tool_call.find(self.tool_call_arg_token)
193
+ if arg_pos == -1:
194
+ return None, None
195
+ header = tool_call[:arg_pos].strip()
196
+ tool_args = tool_call[arg_pos + len(self.tool_call_arg_token) :]
197
+ return header, tool_args
198
+
199
+ def _compute_args_diff(self, index: int, tool_args: str | None) -> str | None:
200
+ """Return new argument text not yet sent for tool `index`, or None."""
201
+ if tool_args is None:
202
+ return None
203
+ prev = self.streamed_args_for_tool[index]
204
+ if len(tool_args) <= len(prev):
205
+ return None
206
+ diff = tool_args[len(prev) :]
207
+ self.streamed_args_for_tool[index] = tool_args
208
+ self.prev_tool_call_arr[index]["arguments"] = tool_args
209
+ return diff
210
+
211
def extract_tool_calls_streaming(
212
self,
213
previous_text: str,
@@ -208,394 +218,59 @@ class KimiK2ToolParser(ToolParser):
218
delta_token_ids: Sequence[int],
219
request: ChatCompletionRequest,
220
) -> DeltaMessage | None:
211
- logger.debug("delta_text: %s", delta_text)
212
- logger.debug("delta_token_ids: %s", delta_token_ids)
213
-
214
- # Flag to defer section exit until after tool parsing completes
215
- deferred_section_exit = False
216
-
217
- # Add delta to buffer for split marker detection
218
- self.token_buffer += delta_text
219
-
220
- # Enforce buffer size limit to prevent memory issues
221
- if len(self.token_buffer) > self.buffer_max_size:
222
- if not self._buffer_overflow_logged:
223
- logger.warning(
224
- "Token buffer exceeded max size (%d bytes), flushing excess. "
225
- "This may indicate very long markers or unusual tokenization.",
226
- self.buffer_max_size,
227
- )
228
- self._buffer_overflow_logged = True
229
- # Keep only the most recent content that might contain partial markers
230
- self.token_buffer = self.token_buffer[-self.buffer_max_size // 2 :]
231
-
232
- # Check buffer for section markers (handles split tokens)
233
- buffered_text, found_section_begin, found_section_end = (
234
- self._check_and_strip_markers(self.token_buffer)
235
- )
236
-
237
- # Track section state transitions
238
- if found_section_begin and not self.in_tool_section:
239
- logger.debug("Entering tool section")
240
- self.in_tool_section = True
241
- self.token_buffer = buffered_text # Use cleaned buffer
242
- self.section_char_count = 0 # Reset counter for new section
243
-
244
- if found_section_end and self.in_tool_section:
245
- logger.debug("Detected section end marker")
246
- # CRITICAL: Don't exit early if tool_call_end is in this chunk.
247
- # Tool parser must emit final arguments/close first to avoid dropping
248
- # the final tool update and leaking tokens into reasoning channel.
249
- has_tool_end = self.tool_call_end_token_id in delta_token_ids
250
- if has_tool_end:
251
- # Defer exit until after tool parsing completes
252
- deferred_section_exit = True
253
- logger.debug("Deferring section exit: tool_call_end in same chunk")
254
- self.token_buffer = buffered_text
255
- else:
256
- # No tool call ending, safe to exit immediately
257
- logger.debug("Exiting tool section")
258
- self._reset_section_state()
259
- # Extract any content AFTER the section end marker in delta_text
260
- # (don't use buffered_text as it contains tool call data)
261
- post_section_content = ""
262
- for variant in self.tool_calls_end_token_variants:
263
- if variant in delta_text:
264
- parts = delta_text.split(variant, 1)
265
- if len(parts) > 1:
266
- post_section_content = parts[1]
267
- break
268
- if post_section_content.strip():
269
- return DeltaMessage(content=post_section_content)
270
- return DeltaMessage(content="")
271
- else:
272
- self.token_buffer = buffered_text
273
-
274
- # Check if any variant of section start token is in current_token_ids
275
- has_section_token = any(
276
- tid in current_token_ids for tid in self.tool_calls_start_token_ids
277
- )
278
-
279
- # Early return: if no section token detected yet, return as reasoning content
280
- if not has_section_token and not self.in_tool_section:
281
- logger.debug("No tool call tokens found!")
282
- # Don't clear buffer - it needs to accumulate partial markers across deltas
283
- # Buffer overflow is already protected by lines 215-224
284
- return DeltaMessage(content=delta_text)
285
-
286
- # Strip section markers from delta_text for subsequent processing
287
- # NOTE: This preprocessing happens BEFORE the regex-based tool call
288
- # parsing (from PR #24847) to ensure markers are removed cleanly
289
- # before pattern matching. No double-stripping occurs because
290
- # section markers and tool call markers are distinct.
291
- delta_text, _, _ = self._check_and_strip_markers(delta_text)
292
-
293
- # Error recovery: If in tool section for too long, force exit
294
- if self.in_tool_section:
295
- self.section_char_count += len(delta_text)
296
- if self.section_char_count > self.max_section_chars:
297
- logger.warning(
298
- "Tool section exceeded max length (%d chars), forcing exit. "
299
- "This may indicate malformed model output.",
300
- self.max_section_chars,
301
- )
302
- self._reset_section_state()
303
- # Deferred exit already handled by forced exit above
304
- # Return remaining content as reasoning (or empty delta if no content)
305
- return DeltaMessage(content=delta_text if delta_text.strip() else "")
306
-
221
try:
308
- # figure out where we are in the parsing by counting tool call
309
- # start & end tags
310
- prev_tool_start_count = previous_token_ids.count(
311
- self.tool_call_start_token_id
312
- )
313
- prev_tool_end_count = previous_token_ids.count(self.tool_call_end_token_id)
314
- cur_tool_start_count = current_token_ids.count(
315
- self.tool_call_start_token_id
316
- )
317
- cur_tool_end_count = current_token_ids.count(self.tool_call_end_token_id)
318
- tool_call_portion = None
319
- text_portion = None
320
-
321
- # case: if we're generating text, OR rounding out a tool call
322
- if (
323
- cur_tool_start_count == cur_tool_end_count
324
- and prev_tool_end_count == cur_tool_end_count
325
- and self.tool_call_end_token not in delta_text
326
- ):
327
- # Suppress content between section begin and first tool begin
328
- # (header noise). Don't suppress content between tools to avoid
329
- # breaking potential delimiter characters.
330
- if self.in_tool_section and cur_tool_start_count == 0:
331
- logger.debug(
332
- "In tool section before first tool, suppressing: %s",
333
- delta_text,
334
- )
335
- # Return empty delta to maintain iterator contract
336
- return DeltaMessage(content="")
337
- logger.debug("Generating text content! skipping tool parsing.")
338
- return DeltaMessage(content=delta_text)
339
-
340
- if self.tool_call_end_token in delta_text:
341
- logger.debug("tool_call_end_token in delta_text")
342
- full_text = current_text + delta_text
343
- tool_call_portion = (
344
- full_text.split(self.tool_call_start_token)[-1]
345
- .split(self.tool_call_end_token)[0]
346
- .rstrip()
347
- )
348
- delta_text = delta_text.split(self.tool_call_end_token)[0].rstrip()
349
- text_portion = delta_text.split(self.tool_call_end_token)[-1].lstrip()
350
-
351
- # case -- we're starting a new tool call
352
- if (
353
- cur_tool_start_count > cur_tool_end_count
354
- and cur_tool_start_count > prev_tool_start_count
355
- ):
356
- if len(delta_token_ids) > 1:
357
- tool_call_portion = current_text.split(self.tool_call_start_token)[
358
- -1
359
- ]
360
- else:
361
- tool_call_portion = None
362
- delta = None
363
-
364
- text_portion = None
365
-
366
- # set cursors and state appropriately
367
- self.current_tool_id += 1
368
- self.current_tool_name_sent = False
369
- self.streamed_args_for_tool.append("")
370
- logger.debug("Starting on a new tool %s", self.current_tool_id)
371
-
372
- # case -- we're updating an existing tool call
373
- elif (
374
- cur_tool_start_count > cur_tool_end_count
375
- and cur_tool_start_count == prev_tool_start_count
376
- ):
377
- # get the portion of the text that's the tool call
378
- tool_call_portion = current_text.split(self.tool_call_start_token)[-1]
379
- text_portion = None
380
-
381
- # case -- the current tool call is being closed.
382
- elif (
383
- cur_tool_start_count == cur_tool_end_count
384
- and cur_tool_end_count >= prev_tool_end_count
385
- ):
386
- if self.prev_tool_call_arr is None or len(self.prev_tool_call_arr) == 0:
387
- logger.debug("attempting to close tool call, but no tool call")
388
- # Handle deferred section exit before returning
389
- if deferred_section_exit and self.in_tool_section:
390
- self._reset_section_state()
391
- return None
392
- diff = self.prev_tool_call_arr[self.current_tool_id].get("arguments")
393
- if diff:
394
- diff = (
395
- diff.encode("utf-8").decode("unicode_escape")
396
- if diff is str
397
- else diff
398
- )
399
- if '"}' not in delta_text:
400
- # Handle deferred section exit before returning
401
- if deferred_section_exit and self.in_tool_section:
402
- self._reset_section_state()
403
- return None
404
- end_loc = delta_text.rindex('"}')
405
- diff = delta_text[:end_loc] + '"}'
406
- logger.debug(
407
- "Finishing tool and found diff that had not "
408
- "been streamed yet: %s",
409
- diff,
410
- )
411
- self.streamed_args_for_tool[self.current_tool_id] += diff
412
- # Handle deferred section exit before returning
413
- if deferred_section_exit and self.in_tool_section:
414
- logger.debug("Completing deferred section exit")
415
- self._reset_section_state()
416
- return DeltaMessage(
417
- tool_calls=[
418
- DeltaToolCall(
419
- index=self.current_tool_id,
420
- function=DeltaFunctionCall(arguments=diff).model_dump(
421
- exclude_none=True
422
- ),
423
- )
424
- ]
425
- )
426
-
427
- # case -- otherwise we're just generating text
428
- else:
429
- # Check if we're in tool section - if so, suppress
430
- if self.in_tool_section:
431
- logger.debug("In tool section, suppressing text generation")
432
- # Handle deferred section exit before returning
433
- if deferred_section_exit:
434
- self._reset_section_state()
435
- return DeltaMessage(content="")
436
- text = delta_text.replace(self.tool_call_start_token, "")
437
- text = text.replace(self.tool_call_end_token, "")
438
- delta = DeltaMessage(tool_calls=[], content=text)
439
- # Handle deferred section exit before returning
440
- if deferred_section_exit and self.in_tool_section:
441
- self._reset_section_state()
442
- return delta
443
-
444
- current_tool_call = dict()
445
- if tool_call_portion:
446
- current_tool_call_matches = self.stream_tool_call_portion_regex.match(
447
- tool_call_portion
448
- )
449
- if current_tool_call_matches:
450
- tool_id, tool_args = current_tool_call_matches.groups()
451
- tool_name = tool_id.split(":")[0].split(".")[-1]
452
- current_tool_call["id"] = tool_id.strip()
453
- current_tool_call["name"] = tool_name
454
- current_tool_call["arguments"] = tool_args
455
- else:
456
- current_tool_call_name_matches = (
457
- self.stream_tool_call_name_regex.match(tool_call_portion)
458
- )
459
- if current_tool_call_name_matches:
460
- (tool_id_str,) = current_tool_call_name_matches.groups()
461
- tool_name = tool_id_str.split(":")[0].split(".")[-1]
462
- current_tool_call["id"] = tool_id_str.strip()
463
- current_tool_call["name"] = tool_name
464
- current_tool_call["arguments"] = ""
465
- else:
466
- logger.debug("Not enough token")
467
- return None
468
-
469
- # case - we haven't sent the tool name yet. If it's available, send
470
- # it. otherwise, wait until it's available.
471
- if not self.current_tool_name_sent:
472
- if current_tool_call is None:
473
- return None
474
- function_name: str | None = current_tool_call.get("name")
475
- tool_id = current_tool_call.get("id")
476
- if function_name:
477
- self.current_tool_name_sent = True
478
- return DeltaMessage(
479
- tool_calls=[
480
- DeltaToolCall(
481
- index=self.current_tool_id,
482
- type="function",
483
- id=tool_id,
484
- function=DeltaFunctionCall(
485
- name=function_name
486
- ).model_dump(exclude_none=True),
487
- )
488
- ]
222
+ # Extract any content before tool calls.
223
+ content = self._extract_content(current_text)
224
+ tool_calls = self._extract_tool_calls(current_text)
225
+ tool_call_deltas: list[DeltaToolCall] = []
226
+
227
+ for i, tool_call in enumerate(tool_calls):
228
+ # First time seeing tool call at index i.
229
+ if i >= len(self.prev_tool_call_arr):
230
+ # Initialize streaming state.
231
+ self.prev_tool_call_arr.append({})
232
+ self.streamed_args_for_tool.append("")
233
+
234
+ header, tool_args = self._split_tool_call(tool_call)
235
+
236
+ # Stream back tool name.
237
+ if "name" not in self.prev_tool_call_arr[i]:
238
+ tool_id, tool_name = self._extract_tool_id_and_name(header)
239
+ if not tool_name:
240
+ # Can't skip to tool i+1 if i isn't ready
241
+ break
242
+ self.prev_tool_call_arr[i]["name"] = tool_name
243
+ self.prev_tool_call_arr[i]["id"] = tool_id
244
+ tool_call_deltas.append(
245
+ DeltaToolCall(
246
+ index=i,
247
+ type="function",
248
+ id=tool_id,
249
+ function=DeltaFunctionCall(name=tool_name).model_dump(
250
+ exclude_none=True
251
+ ),
252
+ )
253
)
490
- else:
491
- return None
492
-
493
- # case -- otherwise, send the tool call delta
494
-
495
- # if the tool call portion is None, send the delta as text
496
- if tool_call_portion is None:
497
- # if there's text but not tool calls, send that -
498
- # otherwise None to skip chunk
499
- # CRITICAL: Never return content if we're in a tool section
500
- if self.in_tool_section:
501
- return None
502
- delta = (
503
- DeltaMessage(content=delta_text)
504
- if text_portion is not None
505
- else None
506
- )
507
- return delta
508
-
509
- # now, the nitty-gritty of tool calls
510
- # now we have the portion to parse as tool call.
511
-
512
- logger.debug(
513
- "Trying to parse current tool call with ID %s", self.current_tool_id
514
- )
515
-
516
- # if we're starting a new tool call, push an empty object in as
517
- # a placeholder for the arguments
518
- if len(self.prev_tool_call_arr) <= self.current_tool_id:
519
- self.prev_tool_call_arr.append({})
520
-
521
- # main logic for tool parsing here - compare prev. partially-parsed
522
- # JSON to the current partially-parsed JSON
523
- prev_arguments = self.prev_tool_call_arr[self.current_tool_id].get(
524
- "arguments"
525
- )
526
- cur_arguments = current_tool_call.get("arguments")
527
-
528
- logger.debug("diffing old arguments: %s", prev_arguments)
529
- logger.debug("against new ones: %s", cur_arguments)
530
-
531
- # case -- no arguments have been created yet. skip sending a delta.
532
- if not cur_arguments and not prev_arguments:
533
- logger.debug("Skipping text %s - no arguments", delta_text)
534
- delta = None
535
-
536
- # case -- prev arguments are defined, but non are now.
537
- # probably impossible, but not a fatal error - just keep going
538
- elif not cur_arguments and prev_arguments:
539
- logger.error(
540
- "should be impossible to have arguments reset "
541
- "mid-call. skipping streaming anything."
542
- )
543
- delta = None
254
545
- # case -- we now have the first info about arguments available from
546
- # autocompleting the JSON
547
- elif cur_arguments and not prev_arguments:
548
- delta = DeltaMessage(
549
- tool_calls=[
255
+ # Stream back new tool args by diffing against what was sent.
256
+ args_diff = self._compute_args_diff(i, tool_args)
257
+ if args_diff:
258
+ tool_call_deltas.append(
259
DeltaToolCall(
551
- index=self.current_tool_id,
552
- function=DeltaFunctionCall(
553
- arguments=cur_arguments
554
- ).model_dump(exclude_none=True),
260
+ index=i,
261
+ function=DeltaFunctionCall(arguments=args_diff).model_dump(
262
+ exclude_none=True
263
+ ),
264
)
556
- ]
557
- )
558
- self.streamed_args_for_tool[self.current_tool_id] = cur_arguments
559
-
560
- # last case -- we have an update to existing arguments.
561
- elif cur_arguments and prev_arguments:
562
- if (
563
- isinstance(delta_text, str)
564
- and cur_arguments != prev_arguments
565
- and len(cur_arguments) > len(prev_arguments)
566
- and cur_arguments.startswith(prev_arguments)
567
- ):
568
- delta_arguments = cur_arguments[len(prev_arguments) :]
569
- logger.debug("got diff %s", delta_text)
570
-
571
- delta = DeltaMessage(
572
- tool_calls=[
573
- DeltaToolCall(
574
- index=self.current_tool_id,
575
- function=DeltaFunctionCall(
576
- arguments=delta_arguments
577
- ).model_dump(exclude_none=True),
578
- )
579
- ]
265
)
581
- self.streamed_args_for_tool[self.current_tool_id] = cur_arguments
582
- else:
583
- delta = None
584
-
585
- # handle saving the state for the current tool into
586
- # the "prev" list for use in diffing for the next iteration
587
- if self.current_tool_id == len(self.prev_tool_call_arr) - 1:
588
- self.prev_tool_call_arr[self.current_tool_id] = current_tool_call
589
- else:
590
- self.prev_tool_call_arr.append(current_tool_call)
591
-
592
- # Handle deferred section exit after tool parsing completes
593
- if deferred_section_exit and self.in_tool_section:
594
- logger.debug("Completing deferred section exit")
595
- self._reset_section_state()
266
597
- return delta
267
+ if content or tool_call_deltas:
268
+ return DeltaMessage(
269
+ content=content,
270
+ tool_calls=tool_call_deltas,
271
+ )
272
+ return None
273
274
except Exception:
275
logger.exception("Error trying to handle streaming tool call.")
601
- return None # do not stream a delta. skip this token ID.
276
+ return None