@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / f00e163f35
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[Frontend] Add Streaming Parser Engine and new MinimaxM2 Parser (#45701)

Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com>

Chauncey committed Jun 17, 2026 at 01:38 UTC f00e163f3562f9587d564ec4ffa3cb32d7a05403
12 files changed +588 -481
tests/parser/engine/test_minimax_m2.py new
+264
@@ -0,0 +1,264 @@
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 tests.parser.engine.conftest import make_mock_tokenizer
9 +from tests.parser.engine.streaming_helpers import (
10 + collect_function_name,
11 + collect_tool_arguments,
12 + simulate_tool_streaming,
13 +)
14 +from vllm.entrypoints.openai.chat_completion.protocol import (
15 + ChatCompletionToolsParam,
16 + FunctionDefinition,
17 +)
18 +from vllm.parser.minimax_m2 import (
19 + THINK_END,
20 + TOOL_CALL_END,
21 + TOOL_CALL_START,
22 + MinimaxM2Parser,
23 +)
24 +
25 +
26 +@pytest.fixture
27 +def mock_tokenizer():
28 + return make_mock_tokenizer(
29 + {
30 + THINK_END: 99,
31 + TOOL_CALL_START: 100,
32 + TOOL_CALL_END: 101,
33 + }
34 + )
35 +
36 +
37 +@pytest.fixture
38 +def parser(mock_tokenizer):
39 + return MinimaxM2Parser(mock_tokenizer)
40 +
41 +
42 +def make_tools(*names: str):
43 + return [
44 + ChatCompletionToolsParam(
45 + function=FunctionDefinition(
46 + name=name,
47 + parameters={
48 + "type": "object",
49 + "properties": {},
50 + },
51 + ),
52 + )
53 + for name in names
54 + ]
55 +
56 +
57 +class TestNonStreaming:
58 + def test_no_tool_calls(self, parser, mock_request):
59 + result = parser.extract_tool_calls(
60 + "</think>This is a regular response without tool calls.",
61 + mock_request,
62 + )
63 + assert result.tools_called is False
64 + assert result.tool_calls == []
65 + assert result.content == "This is a regular response without tool calls."
66 +
67 + def test_single_tool_call(self, parser, mock_request):
68 + result = parser.extract_tool_calls(
69 + '<minimax:tool_call><invoke name="get_weather">'
70 + '<parameter name="city">Seattle</parameter>'
71 + "</invoke></minimax:tool_call>",
72 + mock_request,
73 + )
74 +
75 + assert result.tools_called is True
76 + assert len(result.tool_calls) == 1
77 + assert result.tool_calls[0].function.name == "get_weather"
78 + assert json.loads(result.tool_calls[0].function.arguments) == {
79 + "city": "Seattle",
80 + }
81 +
82 + def test_multiple_invokes(self, parser, mock_request):
83 + result = parser.extract_tool_calls(
84 + "<minimax:tool_call>"
85 + '<invoke name="search"><parameter name="q">OpenAI</parameter></invoke>'
86 + '<invoke name="search"><parameter name="q">vLLM</parameter></invoke>'
87 + "</minimax:tool_call>",
88 + mock_request,
89 + )
90 +
91 + assert result.tools_called is True
92 + assert [tc.function.name for tc in result.tool_calls] == ["search", "search"]
93 + assert json.loads(result.tool_calls[0].function.arguments) == {"q": "OpenAI"}
94 + assert json.loads(result.tool_calls[1].function.arguments) == {"q": "vLLM"}
95 +
96 + def test_schema_type_coercion(self, mock_tokenizer, mock_request):
97 + tools = [
98 + ChatCompletionToolsParam(
99 + function=FunctionDefinition(
100 + name="forecast",
101 + parameters={
102 + "type": "object",
103 + "properties": {
104 + "days": {"type": "integer"},
105 + "include_hourly": {"type": "boolean"},
106 + },
107 + },
108 + ),
109 + )
110 + ]
111 + parser = MinimaxM2Parser(mock_tokenizer, tools=tools)
112 + mock_request.tools = tools
113 +
114 + result = parser.extract_tool_calls(
115 + '<minimax:tool_call><invoke name="forecast">'
116 + '<parameter name="days">5</parameter>'
117 + '<parameter name="include_hourly">true</parameter>'
118 + "</invoke></minimax:tool_call>",
119 + mock_request,
120 + )
121 +
122 + assert json.loads(result.tool_calls[0].function.arguments) == {
123 + "days": 5,
124 + "include_hourly": True,
125 + }
126 +
127 + def test_invalid_tool_name_is_rejected(self, mock_tokenizer, mock_request):
128 + tools = make_tools("search")
129 + parser = MinimaxM2Parser(mock_tokenizer)
130 + mock_request.tools = tools
131 +
132 + result = parser.extract_tool_calls(
133 + '<minimax:tool_call><invoke name="img_gen">'
134 + '<parameter name="prompt">a cat</parameter>'
135 + "</invoke></minimax:tool_call>",
136 + mock_request,
137 + )
138 +
139 + assert result.tools_called is False
140 + assert result.tool_calls == []
141 +
142 + def test_mixed_tool_names_only_return_valid(self, mock_tokenizer, mock_request):
143 + tools = make_tools("search")
144 + parser = MinimaxM2Parser(mock_tokenizer)
145 + mock_request.tools = tools
146 +
147 + result = parser.extract_tool_calls(
148 + "<minimax:tool_call>"
149 + '<invoke name="img_gen"><parameter name="prompt">cat</parameter></invoke>'
150 + '<invoke name="search"><parameter name="query">news</parameter></invoke>'
151 + "</minimax:tool_call>",
152 + mock_request,
153 + )
154 +
155 + assert result.tools_called is True
156 + assert [tc.function.name for tc in result.tool_calls] == ["search"]
157 + assert json.loads(result.tool_calls[0].function.arguments) == {
158 + "query": "news",
159 + }
160 +
161 +
162 +class TestStreaming:
163 + def test_streaming_single_tool_call(self, parser, mock_request):
164 + results = simulate_tool_streaming(
165 + parser,
166 + mock_request,
167 + [
168 + "<minimax:tool_call>",
169 + '<invoke name="get_weather">',
170 + '<parameter name="city">Seattle</parameter>',
171 + "</invoke></minimax:tool_call>",
172 + ],
173 + )
174 +
175 + assert collect_function_name(results) == "get_weather"
176 + assert json.loads(collect_tool_arguments(results)) == {
177 + "city": "Seattle",
178 + }
179 +
180 + def test_streaming_multiple_invokes(self, parser, mock_request):
181 + results = simulate_tool_streaming(
182 + parser,
183 + mock_request,
184 + [
185 + "<minimax:tool_call>",
186 + '<invoke name="a"><parameter name="x">1</parameter></invoke>',
187 + '<invoke name="b"><parameter name="y">2</parameter></invoke>',
188 + "</minimax:tool_call>",
189 + ],
190 + )
191 +
192 + tool_names = [
193 + tc.function.name
194 + for delta, _ in results
195 + if delta and delta.tool_calls
196 + for tc in delta.tool_calls
197 + if tc.function and tc.function.name
198 + ]
199 + assert tool_names == ["a", "b"]
200 +
201 + def test_streaming_invoke_prefix_split_before_quote(self, parser, mock_request):
202 + results = simulate_tool_streaming(
203 + parser,
204 + mock_request,
205 + [
206 + "<minimax:tool_call>",
207 + "<invoke name=",
208 + '"get_weather">',
209 + '<parameter name="city">Seattle</parameter>',
210 + "</invoke></minimax:tool_call>",
211 + ],
212 + )
213 +
214 + assert collect_function_name(results) == "get_weather"
215 + assert json.loads(collect_tool_arguments(results)) == {
216 + "city": "Seattle",
217 + }
218 +
219 + def test_streaming_invalid_tool_name_is_rejected(
220 + self, mock_tokenizer, mock_request
221 + ):
222 + tools = make_tools("search")
223 + parser = MinimaxM2Parser(mock_tokenizer)
224 + mock_request.tools = tools
225 +
226 + results = simulate_tool_streaming(
227 + parser,
228 + mock_request,
229 + [
230 + "<minimax:tool_call>",
231 + '<invoke name="img_gen">',
232 + '<parameter name="prompt">cat</parameter>',
233 + "</invoke></minimax:tool_call>",
234 + ],
235 + )
236 +
237 + assert collect_function_name(results) is None
238 + assert collect_tool_arguments(results) == ""
239 +
240 +
241 +class TestReasoning:
242 + def test_extract_reasoning_without_start_token(self, parser, mock_request):
243 + reasoning, content = parser.extract_reasoning(
244 + "This is reasoning</think>This is content",
245 + mock_request,
246 + )
247 +
248 + assert reasoning == "This is reasoning"
249 + assert content == "This is content"
250 +
251 + def test_extract_reasoning_without_end_token(self, parser, mock_request):
252 + reasoning, content = parser.extract_reasoning(
253 + "This is still reasoning",
254 + mock_request,
255 + )
256 +
257 + assert reasoning == "This is still reasoning"
258 + assert content is None
259 +
260 + def test_extract_content_ids_without_end_token(self, parser):
261 + assert parser.extract_content_ids([1, 2, 3]) == []
262 +
263 + def test_extract_content_ids_after_end_token(self, parser):
264 + assert parser.extract_content_ids([1, 99, 2, 3]) == [2, 3]
tests/parser/engine/trace_builder.py
+75
@@ -30,6 +30,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
30 )
31 from vllm.parser.engine.registered_adapters import (
32 Gemma4Parser,
33 + MinimaxM2Parser,
34 NemotronV3Parser,
35 Qwen3Parser,
36 )
@@ -393,6 +394,79 @@ def _build_qwen3(
394 return sample
395
396
397 +# ── MiniMax M2 (XML invoke format, starts in REASONING) ──────────────
398 +
399 +_MINIMAX_M2_VOCAB: dict[str, int] = {
400 + "<think>": 50,
401 + "</think>": 51,
402 + "<minimax:tool_call>": 60,
403 + "</minimax:tool_call>": 61,
404 +}
405 +
406 +
407 +def _minimax_m2_arg_value(value: Any) -> str:
408 + if isinstance(value, bool):
409 + return "true" if value else "false"
410 + if isinstance(value, (int, float)):
411 + return str(value)
412 + if isinstance(value, str):
413 + return value
414 + return json.dumps(value, ensure_ascii=False)
415 +
416 +
417 +def _minimax_m2_tool_segments(tool_calls: list[ToolCallSpec]) -> list[tuple[str, bool]]:
418 + segs: list[tuple[str, bool]] = [("<minimax:tool_call>", True)]
419 + for tc in tool_calls:
420 + segs.append((f'<invoke name="{tc.name}">', False))
421 + for key, value in tc.arguments.items():
422 + segs.append(
423 + (
424 + f'<parameter name="{key}">'
425 + f"{_minimax_m2_arg_value(value)}"
426 + "</parameter>",
427 + False,
428 + )
429 + )
430 + segs.append(("</invoke>", False))
431 + segs.append(("</minimax:tool_call>", True))
432 + return segs
433 +
434 +
435 +def _minimax_m2_segments(scenario: Scenario) -> list[tuple[str, bool]]:
436 + segs: list[tuple[str, bool]] = []
437 + if scenario.reasoning is not None:
438 + segs.append((scenario.reasoning, False))
439 + if scenario.content is not None or scenario.tool_calls:
440 + segs.append(("</think>", True))
441 + if scenario.content is not None:
442 + segs.append((scenario.content, False))
443 + if scenario.tool_calls:
444 + segs.extend(_minimax_m2_tool_segments(scenario.tool_calls))
445 + return segs
446 +
447 +
448 +def _build_minimax_m2(scenario: Scenario, validate: bool = True) -> Sample:
449 + expected_reasoning: str | None
450 + if scenario.reasoning is not None:
451 + expected_reasoning = scenario.reasoning.rstrip()
452 + else:
453 + expected_reasoning = ""
454 +
455 + sample = _make_sample(
456 + sample_id=f"minimax_m2-{scenario.id}",
457 + description=scenario.description,
458 + vocab=_MINIMAX_M2_VOCAB,
459 + segments=_minimax_m2_segments(scenario),
460 + expected_reasoning=expected_reasoning,
461 + expected_content=_qwen3_expected_content(scenario),
462 + expected_tool_calls=_expected_tc(scenario),
463 + tools=_expected_tools(scenario),
464 + )
465 + if validate:
466 + _validate_sample(sample, MinimaxM2Parser)
467 + return sample
468 +
469 +
470 # ── Gemma4 (channel reasoning, custom arg format) ────────────────────
471
472 _GEMMA4_VOCAB: dict[str, int] = {
@@ -502,6 +576,7 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
576 _BUILDERS: dict[str, Any] = {
577 "qwen3": _build_qwen3,
578 "gemma4": _build_gemma4,
579 + "minimax_m2": _build_minimax_m2,
580 "nemotron_v3": _build_nemotron_v3,
581 }
582
tests/reasoning/test_minimax_m2_reasoning_parser.py
-26
@@ -59,14 +59,6 @@ MULTIPLE_LINES = {
59 "is_reasoning_end": True,
60 }
61
62 -# Case: only end token (empty reasoning, immediate response)
63 -SHORTEST_REASONING_NO_STREAMING = {
64 - "output": "</think>This is the response",
65 - "reasoning": "",
66 - "content": "This is the response",
67 - "is_reasoning_end": True,
68 -}
69 -
62 # Case: only end token streaming (reasoning is None because it's just the token)
63 SHORTEST_REASONING_STREAMING = {
64 "output": "</think>This is the response",
@@ -75,14 +67,6 @@ SHORTEST_REASONING_STREAMING = {
67 "is_reasoning_end": True,
68 }
69
78 -# Case: empty output
79 -EMPTY = {
80 - "output": "",
81 - "reasoning": "",
82 - "content": None,
83 - "is_reasoning_end": False,
84 -}
85 -
70 # Case: empty streaming
71 EMPTY_STREAMING = {
72 "output": "",
@@ -149,21 +133,11 @@ TEST_CASES = [
133 MULTIPLE_LINES,
134 id="multiple_lines_streaming",
135 ),
152 - pytest.param(
153 - False,
154 - SHORTEST_REASONING_NO_STREAMING,
155 - id="shortest_reasoning",
156 - ),
136 pytest.param(
137 True,
138 SHORTEST_REASONING_STREAMING,
139 id="shortest_reasoning_streaming",
140 ),
162 - pytest.param(
163 - False,
164 - EMPTY,
165 - id="empty",
166 - ),
141 pytest.param(
142 True,
143 EMPTY_STREAMING,
tests/tool_parsers/test_minimax_m2_tool_parser.py
+6 -117
@@ -18,7 +18,6 @@ pytestmark = pytest.mark.cpu_test
18 # Token IDs matching FakeTokenizer.vocab
19 TC_START_ID = 1
20 TC_END_ID = 2
21 -EOS_ID = 99
21
22
23 class FakeTokenizer:
@@ -34,6 +33,10 @@ class FakeTokenizer:
33 def get_vocab(self):
34 return self.vocab
35
36 + def decode(self, token_ids):
37 + id_to_token = {v: k for k, v in self.vocab.items()}
38 + return "".join(id_to_token.get(token_id, "") for token_id in token_ids)
39 +
40
41 @pytest.fixture
42 def parser():
@@ -121,7 +124,6 @@ class TestContentStreaming:
124 """No tool call tokens — all text is streamed as content."""
125 results = _feed(parser, ["Hello ", "world"])
126 assert _collect_content(results) == "Hello world"
124 - assert not parser.prev_tool_call_arr
127
128 def test_content_before_tool_call(self, parser):
129 """Text before <minimax:tool_call> is streamed as content."""
@@ -135,7 +137,6 @@ class TestContentStreaming:
137 ],
138 )
139 assert _collect_content(results) == "Let me check. "
138 - assert len(parser.prev_tool_call_arr) == 1
140
141 def test_empty_delta_no_crash(self, parser):
142 """Empty delta_text with no token IDs returns None."""
@@ -262,45 +263,6 @@ class TestMultipleInvokes:
263 assert tc[1]["name"] == "get_stock"
264
265
265 -# ---------------------------------------------------------------------------
266 -# Internal state: prev_tool_call_arr
267 -# ---------------------------------------------------------------------------
268 -
269 -
270 -class TestInternalState:
271 - """Verify prev_tool_call_arr is correct."""
272 -
273 - def test_prev_tool_call_arr_single(self, parser):
274 - _feed(
275 - parser,
276 - [
277 - '<minimax:tool_call><invoke name="fn">'
278 - '<parameter name="a">1</parameter>'
279 - "</invoke></minimax:tool_call>",
280 - ],
281 - )
282 - assert len(parser.prev_tool_call_arr) == 1
283 - assert parser.prev_tool_call_arr[0]["name"] == "fn"
284 - assert parser.prev_tool_call_arr[0]["arguments"] == {"a": "1"}
285 -
286 - def test_prev_tool_call_arr_multiple(self, parser):
287 - """prev_tool_call_arr records each invoke with correct arguments."""
288 - _feed(
289 - parser,
290 - [
291 - "<minimax:tool_call>",
292 - '<invoke name="search"><parameter name="q">hello</parameter></invoke>',
293 - '<invoke name="search"><parameter name="q">world</parameter></invoke>',
294 - "</minimax:tool_call>",
295 - ],
296 - )
297 - assert len(parser.prev_tool_call_arr) == 2
298 - assert parser.prev_tool_call_arr[0]["name"] == "search"
299 - assert parser.prev_tool_call_arr[0]["arguments"] == {"q": "hello"}
300 - assert parser.prev_tool_call_arr[1]["name"] == "search"
301 - assert parser.prev_tool_call_arr[1]["arguments"] == {"q": "world"}
302 -
303 -
266 # ---------------------------------------------------------------------------
267 # DeltaMessage structure
268 # ---------------------------------------------------------------------------
@@ -324,7 +286,7 @@ class TestDeltaMessageFormat:
286 tc = tc_deltas[0]
287 assert tc.index == 0
288 assert tc.type == "function"
327 - assert tc.id is not None and tc.id.startswith("call_")
289 + assert tc.id is not None
290 assert tc.function.name == "fn"
291 assert json.loads(tc.function.arguments) == {"k": "v"}
292
@@ -344,72 +306,6 @@ class TestDeltaMessageFormat:
306 assert indices == [0, 1]
307
308
347 -# ---------------------------------------------------------------------------
348 -# Phase 3: EOS handling
349 -# ---------------------------------------------------------------------------
350 -
351 -
352 -class TestEOSHandling:
353 - """Tests for the end-of-stream phase."""
354 -
355 - def test_eos_after_tool_calls(self, parser):
356 - """EOS token (empty delta, non-special token id) returns content=''."""
357 - results = _feed(
358 - parser,
359 - [
360 - "<minimax:tool_call>",
361 - '<invoke name="fn"><parameter name="k">v</parameter></invoke>',
362 - "</minimax:tool_call>",
363 - # EOS: empty delta_text, non-special token id
364 - ("", [EOS_ID]),
365 - ],
366 - )
367 - # Last result should be the EOS empty-content signal
368 - assert results[-1].content == ""
369 -
370 - def test_end_token_ignored(self, parser):
371 - """</minimax:tool_call> special token should NOT trigger EOS."""
372 - results = _feed(
373 - parser,
374 - [
375 - "<minimax:tool_call>",
376 - '<invoke name="fn"><parameter name="k">v</parameter></invoke>',
377 - # </minimax:tool_call> arrives as special token
378 - ("", [TC_END_ID]),
379 - ],
380 - )
381 - # The tool call delta should be emitted, but no EOS signal
382 - assert not any(r.content == "" and r.tool_calls is None for r in results)
383 -
384 -
385 -# ---------------------------------------------------------------------------
386 -# Start token detection via token IDs
387 -# ---------------------------------------------------------------------------
388 -
389 -
390 -class TestSpecialTokenDetection:
391 - """Start token arrives as a special token (not in delta_text)."""
392 -
393 - def test_start_token_via_id(self, parser):
394 - """<minimax:tool_call> detected via delta_token_ids, not text."""
395 - results = _feed(parser, ["Hello "])
396 - assert _collect_content(results) == "Hello "
397 -
398 - # Start token as special token (empty delta_text)
399 - previous = "Hello "
400 - result = parser.extract_tool_calls_streaming(
401 - previous_text=previous,
402 - current_text=previous,
403 - delta_text="",
404 - previous_token_ids=[],
405 - current_token_ids=[],
406 - delta_token_ids=[TC_START_ID],
407 - request=None,
408 - )
409 - assert result is None # no content to emit
410 - assert parser.is_tool_call_started is True
411 -
412 -
309 # ---------------------------------------------------------------------------
310 # Large chunks (stream_interval > 1)
311 # ---------------------------------------------------------------------------
@@ -419,7 +315,7 @@ class TestLargeChunks:
315 """Simulate stream_interval > 1 where many tokens arrive at once."""
316
317 def test_header_and_params_in_separate_chunks(self, parser):
422 - """Header in chunk 1, all params + close in chunk 2, then EOS."""
318 + """Header in chunk 1, all params + close in chunk 2."""
319 chunk1 = '<minimax:tool_call><invoke name="get_weather">'
320 chunk2 = (
321 '<parameter name="city">Seattle</parameter>'
@@ -432,7 +328,6 @@ class TestLargeChunks:
328 [
329 chunk1,
330 chunk2,
435 - ("", [EOS_ID]),
331 ],
332 )
333
@@ -441,12 +336,6 @@ class TestLargeChunks:
336 parsed = json.loads(tc[0]["arguments"])
337 assert parsed == {"city": "Seattle", "days": "5"}
338
444 - assert len(parser.prev_tool_call_arr) == 1
445 - assert parser.prev_tool_call_arr[0]["arguments"] == {
446 - "city": "Seattle",
447 - "days": "5",
448 - }
449 -
339
340 class TestAnyOfNullableParam:
341 """Regression: anyOf nullable parameter parsing (PR #32342)."""
vllm/parser/engine/incremental_lexer.py
+18 -5
@@ -127,12 +127,14 @@ class IncrementalLexer:
127
128 def flush(self) -> list[LexToken]:
129 tokens: list[LexToken] = []
130 + if self.buffer:
131 + tokens.extend(self._drain(final=True))
132 if self.buffer:
133 tokens.append(LexToken(self.content_terminal, self.buffer))
134 self.buffer = ""
135 return tokens
136
135 - def _drain(self) -> list[LexToken]:
137 + def _drain(self, *, final: bool = False) -> list[LexToken]:
138 tokens: list[LexToken] = []
139 first_chars = self._literal_first_chars
140 content_terminal = self.content_terminal
@@ -161,11 +163,22 @@ class IncrementalLexer:
163 ):
164 best_match = (name, lit, len(lit))
165
164 - if self.buffer in prefix_set:
166 + # If the current buffer is both a complete literal and the prefix
167 + # of a longer literal, wait for the next chunk. For example,
168 + # "<invoke name=" should not be emitted before the next chunk
169 + # proves whether this is the quoted form '<invoke name="'.
170 + if self.buffer in prefix_set and not final:
171 if best_match is not None:
166 - tokens.append(LexToken(best_match[0], best_match[1]))
167 - self.buffer = self.buffer[best_match[2] :]
168 - continue
172 + longer_match = False
173 + for lit, _ in literals_by_first.get(first, ()):
174 + if len(lit) > best_match[2] and lit.startswith(self.buffer):
175 + longer_match = True
176 + break
177 + if not longer_match:
178 + tokens.append(LexToken(best_match[0], best_match[1]))
179 + self.buffer = self.buffer[best_match[2] :]
180 + continue
181 + break
182 else:
183 break
184
vllm/parser/engine/parser_engine.py
+16 -4
@@ -30,6 +30,7 @@ from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
30 from vllm.tool_parsers.utils import (
31 coerce_to_schema_type,
32 extract_types_from_schema,
33 + find_tool_name,
34 find_tool_properties,
35 )
36
@@ -319,15 +320,24 @@ class ParserEngine(Parser):
320 return json.dumps(args, ensure_ascii=False)
321 return args_json
322
323 + def _is_valid_tool_name(self, name: str) -> bool:
324 + if not self.parser_engine_config.validate_tool_names:
325 + return True
326 + if not self._tools:
327 + return True
328 + return find_tool_name(self._tools, name)
329 +
330 # ── Private helpers ─────────────────────────────────────────────
331
332 def _check_skip_tool_parsing(
333 self,
334 request: ChatCompletionRequest | ResponsesRequest,
335 ) -> None:
336 + tools = getattr(request, "tools", None)
337 + if tools:
338 + self._tools = tools
339 if not self.skip_tool_parsing:
340 tool_choice = getattr(request, "tool_choice", None)
330 - tools = getattr(request, "tools", None)
341 if tool_choice == "none" and tools:
342 self.skip_tool_parsing = True
343
@@ -467,6 +477,7 @@ class ParserEngine(Parser):
477 output, this method starts the parser engine in ``CONTENT`` state
478 so it can parse content that has already had reasoning stripped.
479 """
480 + self._check_skip_tool_parsing(request)
481 _, parsed_content, tool_call_info = self._single_pass_parse(
482 content,
483 [],
@@ -586,6 +597,7 @@ class ParserEngine(Parser):
597 enable_auto_tools: bool = False,
598 model_output_token_ids: Sequence[int] = (),
599 ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
600 + self._check_skip_tool_parsing(request)
601 reasoning, content, tool_call_info = self._single_pass_parse(
602 model_output,
603 model_output_token_ids,
@@ -705,7 +717,7 @@ class ParserEngine(Parser):
717 deltas: list[DeltaToolCall],
718 name: str | None,
719 ) -> None:
708 - if not name:
720 + if not name or not self._is_valid_tool_name(name):
721 return
722 slot = self._tool_slots[idx]
723 slot.name = name
@@ -762,7 +774,7 @@ class ParserEngine(Parser):
774
775 if not slot.name_sent:
776 name = slot.name or self._try_extract_name(idx)
765 - if name:
777 + if name and self._is_valid_tool_name(name):
778 slot.name = name
779 slot.name_sent = True
780 self._ensure_tool_id(slot, name)
@@ -934,7 +946,7 @@ class ParserEngine(Parser):
946 else:
947 args_json = "{}"
948
937 - if name:
949 + if name and self._is_valid_tool_name(name):
950 self._ensure_tool_id(slot, name)
951 args_json = self._fix_arg_types(args_json, name)
952 tool_calls.append(
vllm/parser/engine/parser_engine_config.py
+3
@@ -99,6 +99,9 @@ class ParserEngineConfig:
99 # .strip() content text when tool calls are present.
100 strip_content_whitespace_with_tools: bool = True
101
102 + # Reject tool calls whose names are absent from the request tools.
103 + validate_tool_names: bool = False
104 +
105 drop_tokens: frozenset[str] = field(default_factory=frozenset)
106
107 @cached_property
vllm/parser/engine/registered_adapters.py
+6
@@ -9,9 +9,15 @@ names so that :class:`ReasoningParserManager` and
9
10 from vllm.parser.engine.adapters import make_adapters
11 from vllm.parser.gemma4 import Gemma4Parser
12 +from vllm.parser.minimax_m2 import MinimaxM2Parser
13 from vllm.parser.nemotron_v3 import NemotronV3Parser
14 from vllm.parser.qwen3 import Qwen3Parser
15
16 +(
17 + MinimaxM2ParserReasoningAdapter,
18 + MinimaxM2ParserToolAdapter,
19 +) = make_adapters(MinimaxM2Parser)
20 +
21 (
22 Gemma4ParserReasoningAdapter,
23 Gemma4ParserToolAdapter,
vllm/parser/minimax_m2.py new
+180
@@ -0,0 +1,180 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""MiniMax M2 parser for XML-style tool calls.
4 +
5 +MiniMax M2 tool call format::
6 +
7 + <minimax:tool_call><invoke name="get_weather">
8 + <parameter name="city">Seattle</parameter>
9 + </invoke></minimax:tool_call>
10 +
11 +Each ``<invoke>`` block becomes one tool call. The argument body consists
12 +of ``<parameter name="...">...</parameter>`` tags.
13 +"""
14 +
15 +from __future__ import annotations
16 +
17 +import functools
18 +import json
19 +
20 +import regex as re
21 +
22 +from vllm.parser.engine.events import EventType
23 +from vllm.parser.engine.parser_engine import ParserEngine
24 +from vllm.parser.engine.parser_engine_config import (
25 + ParserEngineConfig,
26 + ParserState,
27 + Transition,
28 +)
29 +
30 +TOOL_CALL_START = "<minimax:tool_call>"
31 +TOOL_CALL_END = "</minimax:tool_call>"
32 +THINK_START = "<think>"
33 +THINK_END = "</think>"
34 +INVOKE_PREFIX_DQ = '<invoke name="'
35 +INVOKE_PREFIX_SQ = "<invoke name='"
36 +INVOKE_PREFIX_UNQUOTED = "<invoke name="
37 +INVOKE_END = "</invoke>"
38 +NAME_END_DQ = '">'
39 +NAME_END_SQ = "'>"
40 +NAME_END_UNQUOTED = ">"
41 +
42 +_PARAM_RE = re.compile(
43 + r"<\s*parameter\s+name\s*=\s*"
44 + r"(?:\"(?P<dq_name>[^\"]*)\"|'(?P<sq_name>[^']*)'|(?P<bare_name>[^>\s]+))"
45 + r"\s*>"
46 + r"(?P<value>.*?)"
47 + r"<\s*/\s*parameter\s*>",
48 + re.DOTALL,
49 +)
50 +
51 +
52 +def _minimax_m2_arg_converter(raw_args: str, partial: bool) -> str:
53 + params: dict[str, object] = {}
54 +
55 + for match in _PARAM_RE.finditer(raw_args):
56 + name = (
57 + match.group("dq_name")
58 + or match.group("sq_name")
59 + or match.group("bare_name")
60 + or ""
61 + ).strip()
62 + if not name:
63 + continue
64 + params[name] = match.group("value").strip()
65 +
66 + return json.dumps(params, ensure_ascii=False)
67 +
68 +
69 +@functools.cache
70 +def minimax_m2_config() -> ParserEngineConfig:
71 + return ParserEngineConfig(
72 + name="minimax_m2",
73 + initial_state=ParserState.REASONING,
74 + terminals={
75 + "THINK_START": THINK_START,
76 + "THINK_END": THINK_END,
77 + "TOOL_START": TOOL_CALL_START,
78 + "TOOL_END": TOOL_CALL_END,
79 + "INVOKE_PREFIX_DQ": INVOKE_PREFIX_DQ,
80 + "INVOKE_PREFIX_SQ": INVOKE_PREFIX_SQ,
81 + "INVOKE_PREFIX_UNQUOTED": INVOKE_PREFIX_UNQUOTED,
82 + "INVOKE_END": INVOKE_END,
83 + "NAME_END_DQ": NAME_END_DQ,
84 + "NAME_END_SQ": NAME_END_SQ,
85 + "NAME_END_UNQUOTED": NAME_END_UNQUOTED,
86 + },
87 + token_id_terminals={
88 + "THINK_START": THINK_START,
89 + "THINK_END": THINK_END,
90 + "TOOL_START": TOOL_CALL_START,
91 + "TOOL_END": TOOL_CALL_END,
92 + },
93 + transitions={
94 + (ParserState.REASONING, "THINK_START"): Transition(
95 + ParserState.REASONING,
96 + (),
97 + ),
98 + (ParserState.REASONING, "THINK_END"): Transition(
99 + ParserState.CONTENT,
100 + (EventType.REASONING_END,),
101 + ),
102 + (ParserState.CONTENT, "THINK_END"): Transition(
103 + ParserState.CONTENT,
104 + (),
105 + ),
106 + (ParserState.REASONING, "TOOL_START"): Transition(
107 + ParserState.TOOL_PREAMBLE,
108 + (EventType.REASONING_END,),
109 + ),
110 + (ParserState.CONTENT, "TOOL_START"): Transition(
111 + ParserState.TOOL_PREAMBLE,
112 + (),
113 + ),
114 + (ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
115 + ParserState.CONTENT,
116 + (),
117 + ),
118 + (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
119 + ParserState.CONTENT,
120 + (),
121 + ),
122 + (ParserState.CONTENT, "TOOL_END"): Transition(
123 + ParserState.CONTENT,
124 + (),
125 + ),
126 + (ParserState.TOOL_ARGS, "INVOKE_END"): Transition(
127 + ParserState.TOOL_BETWEEN,
128 + (EventType.TOOL_CALL_END,),
129 + ),
130 + **{
131 + (state, terminal): Transition(
132 + ParserState.TOOL_NAME,
133 + (EventType.TOOL_CALL_START,),
134 + )
135 + for state in (
136 + ParserState.CONTENT,
137 + ParserState.TOOL_PREAMBLE,
138 + ParserState.TOOL_BETWEEN,
139 + )
140 + for terminal in (
141 + "INVOKE_PREFIX_DQ",
142 + "INVOKE_PREFIX_SQ",
143 + "INVOKE_PREFIX_UNQUOTED",
144 + )
145 + },
146 + **{
147 + (ParserState.TOOL_NAME, terminal): Transition(
148 + ParserState.TOOL_ARGS,
149 + (),
150 + )
151 + for terminal in (
152 + "NAME_END_DQ",
153 + "NAME_END_SQ",
154 + "NAME_END_UNQUOTED",
155 + )
156 + },
157 + },
158 + arg_converter=_minimax_m2_arg_converter,
159 + stream_arg_deltas=True,
160 + tool_args_json=False,
161 + validate_tool_names=True,
162 + )
163 +
164 +
165 +class MinimaxM2Parser(ParserEngine):
166 + """MiniMax M2 parser backed by the declarative parser engine."""
167 +
168 + def __init__(self, tokenizer, tools=None, **kwargs) -> None:
169 + kwargs.setdefault("parser_engine_config", minimax_m2_config())
170 + super().__init__(tokenizer, tools, **kwargs)
171 + self._think_end_token_id = self.vocab.get(THINK_END)
172 +
173 + def extract_content_ids(self, input_ids: list[int]) -> list[int]:
174 + end_id = self._think_end_token_id
175 + if end_id is None:
176 + return []
177 + for i in range(len(input_ids) - 1, -1, -1):
178 + if input_ids[i] == end_id:
179 + return input_ids[i + 1 :]
180 + return []
vllm/reasoning/minimax_m2_reasoning_parser.py
+2 -51
@@ -8,8 +8,8 @@ from vllm.entrypoints.openai.engine.protocol import (
8 DeltaMessage,
9 )
10 from vllm.logger import init_logger
11 +from vllm.parser.engine.registered_adapters import MinimaxM2ParserReasoningAdapter
12 from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
12 -from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
13 from vllm.tokenizers import TokenizerLike
14
15 if TYPE_CHECKING:
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
19 logger = init_logger(__name__)
20
21
22 -class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser):
22 +class MiniMaxM2ReasoningParser(MinimaxM2ParserReasoningAdapter): # type: ignore[valid-type, misc]
23 """
24 Reasoning parser for MiniMax M2 model.
25
@@ -28,55 +28,6 @@ class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser):
28 actual response.
29 """
30
31 - @property
32 - def start_token(self) -> str:
33 - """The token that starts reasoning content."""
34 - return "<think>"
35 -
36 - @property
37 - def end_token(self) -> str:
38 - """The token that ends reasoning content."""
39 - return "</think>"
40 -
41 - def extract_reasoning_streaming(
42 - self,
43 - previous_text: str,
44 - current_text: str,
45 - delta_text: str,
46 - previous_token_ids: Sequence[int],
47 - current_token_ids: Sequence[int],
48 - delta_token_ids: Sequence[int],
49 - ) -> DeltaMessage | None:
50 - """
51 - Extract reasoning content from a delta message for streaming.
52 -
53 - MiniMax M2 models don't generate <think> start token, so we assume
54 - all content is reasoning until we encounter the </think> end token.
55 - """
56 - # Skip single end token
57 - if len(delta_token_ids) == 1 and delta_token_ids[0] == self.end_token_id:
58 - return None
59 -
60 - # Check if end token has already appeared in previous tokens
61 - # meaning we're past the reasoning phase
62 - if self.end_token_id in previous_token_ids:
63 - # We're past the reasoning phase, this is content
64 - return DeltaMessage(content=delta_text)
65 -
66 - # Check if end token is in delta tokens
67 - if self.end_token_id in delta_token_ids:
68 - # End token in delta, split reasoning and content
69 - end_index = delta_text.find(self.end_token)
70 - reasoning = delta_text[:end_index]
71 - content = delta_text[end_index + len(self.end_token) :]
72 - return DeltaMessage(
73 - reasoning=reasoning if reasoning else None,
74 - content=content if content else None,
75 - )
76 -
77 - # No end token yet, all content is reasoning
78 - return DeltaMessage(reasoning=delta_text)
79 -
31
32 class MiniMaxM2AppendThinkReasoningParser(ReasoningParser):
33 """
vllm/tool_parsers/minimax_m2_tool_parser.py
+2 -278
@@ -1,284 +1,8 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -import json
5 -import uuid
6 -from collections.abc import Sequence
4 +from vllm.parser.engine.registered_adapters import MinimaxM2ParserToolAdapter
5
8 -import regex as re
6
10 -from vllm.entrypoints.openai.chat_completion.protocol import (
11 - ChatCompletionRequest,
12 -)
13 -from vllm.entrypoints.openai.engine.protocol import (
14 - DeltaFunctionCall,
15 - DeltaMessage,
16 - DeltaToolCall,
17 - ExtractedToolCallInformation,
18 - FunctionCall,
19 - ToolCall,
20 -)
21 -from vllm.logger import init_logger
22 -from vllm.tokenizers import TokenizerLike
23 -from vllm.tool_parsers.abstract_tool_parser import (
24 - Tool,
25 - ToolParser,
26 -)
27 -from vllm.tool_parsers.utils import (
28 - coerce_to_schema_type,
29 - extract_types_from_schema,
30 - find_tool_properties,
31 -)
32 -
33 -logger = init_logger(__name__)
34 -
35 -
36 -class MinimaxM2ToolParser(ToolParser):
7 +class MinimaxM2ToolParser(MinimaxM2ParserToolAdapter): # type: ignore[valid-type, misc]
8 structural_tag_model = "minimax"
38 -
39 - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
40 - super().__init__(tokenizer, tools)
41 -
42 - self.prev_tool_call_arr: list[dict] = []
43 -
44 - # Sentinel tokens
45 - self.tool_call_start_token: str = "<minimax:tool_call>"
46 - self.tool_call_end_token: str = "</minimax:tool_call>"
47 -
48 - # Streaming state
49 - self.is_tool_call_started: bool = False
50 - self.current_tool_index: int = 0
51 -
52 - # Regex patterns for complete parsing
53 - self.tool_call_complete_regex = re.compile(
54 - r"<minimax:tool_call>(.*?)</minimax:tool_call>", re.DOTALL
55 - )
56 - self.invoke_complete_regex = re.compile(
57 - r"<invoke name=(.*?)</invoke>", re.DOTALL
58 - )
59 - self.parameter_complete_regex = re.compile(
60 - r"<parameter name=(.*?)</parameter>", re.DOTALL
61 - )
62 -
63 - if not self.model_tokenizer:
64 - raise ValueError(
65 - "The model tokenizer must be passed to the ToolParser "
66 - "constructor during construction."
67 - )
68 -
69 - self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
70 - self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
71 -
72 - if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None:
73 - raise RuntimeError(
74 - "MiniMax M2 Tool parser could not locate tool call start/end "
75 - "tokens in the tokenizer!"
76 - )
77 -
78 - logger.debug(
79 - "vLLM Successfully import tool parser %s !", self.__class__.__name__
80 - )
81 -
82 - def _generate_tool_call_id(self) -> str:
83 - """Generate a unique tool call ID."""
84 - return f"call_{uuid.uuid4().hex[:24]}"
85 -
86 - def _extract_name(self, name_str: str) -> str:
87 - """Extract name from quoted string."""
88 - name_str = name_str.strip()
89 - if (name_str.startswith('"') and name_str.endswith('"')) or (
90 - name_str.startswith("'") and name_str.endswith("'")
91 - ):
92 - return name_str[1:-1]
93 - return name_str
94 -
95 - def _parse_single_invoke(
96 - self, invoke_str: str, tools: list | None
97 - ) -> ToolCall | None:
98 - """Parse a single <invoke> block."""
99 - # Extract function name
100 - name_match = re.search(r"^([^>]+)", invoke_str)
101 - if not name_match:
102 - return None
103 -
104 - function_name = self._extract_name(name_match.group(1))
105 - tool_properties = find_tool_properties(tools, function_name)
106 -
107 - # Extract parameters
108 - param_dict = {}
109 - for match in self.parameter_complete_regex.findall(invoke_str):
110 - param_match = re.search(r"^([^>]+)>(.*)", match, re.DOTALL)
111 - if param_match:
112 - param_name = self._extract_name(param_match.group(1))
113 - param_value = param_match.group(2).strip()
114 - param_types = extract_types_from_schema(
115 - tool_properties.get(param_name, {})
116 - )
117 - param_dict[param_name] = coerce_to_schema_type(param_value, param_types)
118 -
119 - return ToolCall(
120 - type="function",
121 - function=FunctionCall(
122 - name=function_name,
123 - arguments=json.dumps(param_dict, ensure_ascii=False),
124 - ),
125 - )
126 -
127 - def _extract_delta_tool_calls(
128 - self,
129 - current_text: str,
130 - request: ChatCompletionRequest | None,
131 - ) -> list[DeltaToolCall]:
132 - """Extract DeltaToolCalls from newly completed <invoke> blocks.
133 -
134 - Tracks progress via ``current_tool_index`` so each block is
135 - extracted exactly once across successive streaming calls.
136 - """
137 - complete_invokes = self.invoke_complete_regex.findall(current_text)
138 - delta_tool_calls: list[DeltaToolCall] = []
139 -
140 - while len(complete_invokes) > self.current_tool_index:
141 - invoke_str = complete_invokes[self.current_tool_index]
142 - tool_call = self._parse_single_invoke(
143 - invoke_str,
144 - self.tools,
145 - )
146 - if not tool_call:
147 - self.current_tool_index += 1
148 - continue
149 -
150 - args_json = tool_call.function.arguments
151 - idx = self.current_tool_index
152 - self.current_tool_index += 1
153 -
154 - self.prev_tool_call_arr.append(
155 - {
156 - "name": tool_call.function.name,
157 - "arguments": json.loads(args_json),
158 - }
159 - )
160 - self.streamed_args_for_tool.append(args_json)
161 - delta_tool_calls.append(
162 - DeltaToolCall(
163 - index=idx,
164 - id=self._generate_tool_call_id(),
165 - function=DeltaFunctionCall(
166 - name=tool_call.function.name,
167 - arguments=args_json,
168 - ),
169 - type="function",
170 - )
171 - )
172 -
173 - return delta_tool_calls
174 -
175 - def extract_tool_calls(
176 - self,
177 - model_output: str,
178 - request: ChatCompletionRequest,
179 - ) -> ExtractedToolCallInformation:
180 - """Extract tool calls from complete model output (non-streaming)."""
181 - # Quick check
182 - if self.tool_call_start_token not in model_output:
183 - return ExtractedToolCallInformation(
184 - tools_called=False, tool_calls=[], content=model_output
185 - )
186 -
187 - try:
188 - tool_calls = []
189 -
190 - # Find all complete tool_call blocks
191 - for tool_call_match in self.tool_call_complete_regex.findall(model_output):
192 - # Find all invokes within this tool_call
193 - for invoke_match in self.invoke_complete_regex.findall(tool_call_match):
194 - tool_call = self._parse_single_invoke(invoke_match, self.tools)
195 - if tool_call:
196 - tool_calls.append(tool_call)
197 -
198 - if not tool_calls:
199 - return ExtractedToolCallInformation(
200 - tools_called=False, tool_calls=[], content=model_output
201 - )
202 -
203 - # Update prev_tool_call_arr
204 - self.prev_tool_call_arr.clear()
205 - for tool_call in tool_calls:
206 - self.prev_tool_call_arr.append(
207 - {
208 - "name": tool_call.function.name,
209 - "arguments": tool_call.function.arguments,
210 - }
211 - )
212 -
213 - # Extract content before first tool call
214 - first_tool_idx = model_output.find(self.tool_call_start_token)
215 - content = model_output[:first_tool_idx] if first_tool_idx > 0 else None
216 -
217 - return ExtractedToolCallInformation(
218 - tools_called=True, tool_calls=tool_calls, content=content
219 - )
220 -
221 - except Exception:
222 - logger.exception("Error extracting tool calls")
223 - return ExtractedToolCallInformation(
224 - tools_called=False, tool_calls=[], content=model_output
225 - )
226 -
227 - def extract_tool_calls_streaming(
228 - self,
229 - previous_text: str,
230 - current_text: str,
231 - delta_text: str,
232 - previous_token_ids: Sequence[int], # pylint: disable=unused-argument
233 - current_token_ids: Sequence[int], # pylint: disable=unused-argument
234 - delta_token_ids: Sequence[int],
235 - request: ChatCompletionRequest,
236 - ) -> DeltaMessage | None:
237 - """Extract tool calls from streaming model output.
238 -
239 - Uses a buffer-until-complete-invoke strategy: tokens are buffered
240 - until a complete ``<invoke>...</invoke>`` block is available, then
241 - parsed and emitted in one shot.
242 - """
243 -
244 - start_in_text = self.tool_call_start_token in delta_text
245 - start_in_ids = self.tool_call_start_token_id in delta_token_ids
246 - tool_call_starting = start_in_text or start_in_ids
247 - # Reset state on new request (parser is reused) or new tool-call block.
248 - if not previous_text or tool_call_starting:
249 - self.current_tool_index = 0
250 - self.prev_tool_call_arr.clear()
251 - self.streamed_args_for_tool.clear()
252 - self.is_tool_call_started = tool_call_starting
253 -
254 - # Pass through content before any tool call.
255 - if not self.is_tool_call_started:
256 - return DeltaMessage(content=delta_text) if delta_text else None
257 -
258 - # Capture content before the start token.
259 - content_before = None
260 - if start_in_text:
261 - before = delta_text[: delta_text.index(self.tool_call_start_token)]
262 - content_before = before or None
263 -
264 - # Extract newly completed <invoke> blocks as DeltaToolCalls.
265 - delta_tool_calls = self._extract_delta_tool_calls(current_text, request)
266 -
267 - if delta_tool_calls or content_before:
268 - return DeltaMessage(
269 - content=content_before,
270 - tool_calls=delta_tool_calls,
271 - )
272 -
273 - # EOS and </minimax:tool_call> both arrive as special tokens with
274 - # no decoded text. Return non-None for EOS so the serving framework
275 - # reaches the finish-reason handling path instead of skipping.
276 - if (
277 - not delta_text
278 - and delta_token_ids
279 - and self.prev_tool_call_arr
280 - and self.tool_call_end_token_id not in delta_token_ids
281 - ):
282 - return DeltaMessage(content="")
283 -
284 - return None
vllm/tool_parsers/utils.py
+16
@@ -182,6 +182,22 @@ def find_tool_properties(
182 return {}
183
184
185 +def find_tool_name(
186 + tools: list[Tool] | None,
187 + tool_name: str,
188 +) -> bool:
189 + """Return whether a function tool with *tool_name* exists."""
190 + if not tools:
191 + return False
192 + for tool in tools:
193 + if not _is_function_tool(tool):
194 + continue
195 + name, _ = _extract_tool_info(tool)
196 + if name == tool_name:
197 + return True
198 + return False
199 +
200 +
201 def _get_tool_schema_from_tool(tool: Tool) -> dict:
202 name, params = _extract_tool_info(tool)
203 params = params if params else {"type": "object", "properties": {}}