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": {}}