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 11, 08:45 UTC
[Bugfix][Frontend] Prevent IndexError in MiniMax M2 tool parser during streaming extraction (#30555)
Signed-off-by: WangErXiao <863579016@qq.com>
Robin committed
Dec 17, 2025 at 16:37 UTC
20fda431515d19a883cc962d3a1fa727f225e82d
2 files changed
+137
-4
tests/tool_use/test_minimax_m2_tool_parser.py
new
+119
@@ -0,0 +1,119 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import json
5
+
6
+import pytest
7
+
8
+from vllm.tool_parsers.minimax_m2_tool_parser import (
9
+ MinimaxM2ToolParser,
10
+)
11
+
12
+pytestmark = pytest.mark.cpu_test
13
+
14
+
15
+class FakeTokenizer:
16
+ """Minimal fake tokenizer that exposes the attributes used by the
17
+ parser: a truthy model_tokenizer marker and a vocab mapping for the
18
+ special tokens.
19
+ """
20
+
21
+ def __init__(self):
22
+ self.model_tokenizer = True
23
+ # The parser will look up start/end tokens by their literal strings
24
+ self.vocab = {
25
+ "<minimax:tool_call>": 1,
26
+ "</minimax:tool_call>": 2,
27
+ }
28
+
29
+ def get_vocab(self):
30
+ return self.vocab
31
+
32
+
33
+@pytest.fixture
34
+def minimax_m2_tool_parser():
35
+ return MinimaxM2ToolParser(FakeTokenizer())
36
+
37
+
38
+def test_extract_tool_calls_streaming_incremental(minimax_m2_tool_parser):
39
+ parser = minimax_m2_tool_parser
40
+ parser._reset_streaming_state()
41
+ chunks = [
42
+ "<minimax:tool_call>",
43
+ '<invoke name="get_weather">',
44
+ '<parameter name="city">',
45
+ "Seattle</parameter>",
46
+ "</invoke></minimax:tool_call>",
47
+ ]
48
+ previous = ""
49
+ for chunk in chunks:
50
+ current = previous + chunk
51
+ delta = chunk
52
+ parser.extract_tool_calls_streaming(
53
+ previous_text=previous,
54
+ current_text=current,
55
+ delta_text=delta,
56
+ previous_token_ids=[],
57
+ current_token_ids=[],
58
+ delta_token_ids=[],
59
+ request=None,
60
+ )
61
+ previous = current
62
+
63
+ assert len(parser.prev_tool_call_arr) == 1
64
+ entry = parser.prev_tool_call_arr[0]
65
+
66
+ assert entry["name"] == "get_weather"
67
+ args = entry["arguments"]
68
+ assert args["city"] == "Seattle"
69
+
70
+
71
+def test_streaming_minimax_m2_multiple_invokes(minimax_m2_tool_parser):
72
+ parser = minimax_m2_tool_parser
73
+ parser._reset_streaming_state()
74
+
75
+ chunks = [
76
+ "<minimax:tool_call>",
77
+ '<invoke name="search_web">',
78
+ '<parameter name="query_tag">',
79
+ '["technology", "events"]</parameter>',
80
+ '<parameter name="query_list">',
81
+ '["OpenAI", "latest", "release"]</parameter>',
82
+ "</invoke>",
83
+ '<invoke name="search_web">',
84
+ '<parameter name="query_tag">',
85
+ '["technology", "events"]</parameter>',
86
+ '<parameter name="query_list">',
87
+ '["Gemini", "latest", "release"]</parameter>',
88
+ "</invoke>",
89
+ "</minimax:tool_call>",
90
+ ]
91
+ previous = ""
92
+ for chunk in chunks:
93
+ current = previous + chunk
94
+ delta = chunk
95
+ parser.extract_tool_calls_streaming(
96
+ previous_text=previous,
97
+ current_text=current,
98
+ delta_text=delta,
99
+ previous_token_ids=[],
100
+ current_token_ids=[],
101
+ delta_token_ids=[],
102
+ request=None,
103
+ )
104
+ previous = current
105
+
106
+ assert len(parser.prev_tool_call_arr) == 2
107
+
108
+ for entry, expect_model in zip(parser.prev_tool_call_arr, ["OpenAI", "Gemini"]):
109
+ assert entry["name"] == "search_web"
110
+ args = json.dumps(entry["arguments"])
111
+ assert "technology" in args and "events" in args
112
+ assert expect_model in args
113
+
114
+ # check streamed_args_for_tool for serving_chat.py
115
+ for index in range(2):
116
+ expected_call = parser.prev_tool_call_arr[index].get("arguments", {})
117
+ expected_call = json.dumps(expected_call)
118
+ actual_call = parser.streamed_args_for_tool[index]
119
+ assert expected_call == actual_call
vllm/tool_parsers/minimax_m2_tool_parser.py
+18
-4
@@ -122,6 +122,8 @@ class MinimaxM2ToolParser(ToolParser):
122
self.streaming_request = None
123
# Clear previous tool call history to avoid state pollution
124
self.prev_tool_call_arr.clear()
125
+ # Reset streamed args tracking
126
+ self.streamed_args_for_tool.clear()
127
128
def _extract_name(self, name_str: str) -> str:
129
"""Extract name from quoted string."""
@@ -421,9 +423,12 @@ class MinimaxM2ToolParser(ToolParser):
423
self.prev_tool_call_arr.append(
424
{
425
"name": self.current_function_name,
424
- "arguments": "{}", # Placeholder, will be updated later
426
+ "arguments": {}, # Placeholder, will be updated later
427
}
428
)
429
+ # Initialize streamed_args_for_tool for this tool call
430
+ if len(self.streamed_args_for_tool) <= self.current_tool_index:
431
+ self.streamed_args_for_tool.append("")
432
433
# Send header with function info
434
return DeltaMessage(
@@ -445,6 +450,9 @@ class MinimaxM2ToolParser(ToolParser):
450
# Send opening brace if not sent yet
451
if self.in_function and not self.json_started:
452
self.json_started = True
453
+ # Update streamed_args_for_tool for opening brace
454
+ if self.current_tool_index < len(self.streamed_args_for_tool):
455
+ self.streamed_args_for_tool[self.current_tool_index] += "{"
456
return DeltaMessage(
457
tool_calls=[
458
DeltaToolCall(
@@ -493,7 +501,7 @@ class MinimaxM2ToolParser(ToolParser):
501
args = parsed_tool.function.arguments
502
self.prev_tool_call_arr[self.current_tool_index][
503
"arguments"
496
- ] = args
504
+ ] = json.loads(args)
505
except Exception:
506
pass # Ignore parsing errors during streaming
507
@@ -505,7 +513,9 @@ class MinimaxM2ToolParser(ToolParser):
513
)
514
]
515
)
508
-
516
+ # Update streamed_args_for_tool for closing brace
517
+ if self.current_tool_index < len(self.streamed_args_for_tool):
518
+ self.streamed_args_for_tool[self.current_tool_index] += "}"
519
# Reset state for next tool
520
self.json_closed = True
521
self.in_function = False
@@ -630,7 +640,11 @@ class MinimaxM2ToolParser(ToolParser):
640
)
641
642
self.param_count += 1
633
-
643
+ # Update streamed_args_for_tool for this tool call
644
+ if self.current_tool_index < len(self.streamed_args_for_tool):
645
+ self.streamed_args_for_tool[self.current_tool_index] += (
646
+ json_fragment
647
+ )
648
return DeltaMessage(
649
tool_calls=[
650
DeltaToolCall(