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

Signed-off-by: chaunceyjiang <[email protected]>
This commit is contained in:
Chauncey
2026-06-16 13:38:17 -04:00
committed by GitHub
parent 44b2512767
commit f00e163f35
12 changed files with 588 additions and 481 deletions
+264
View File
@@ -0,0 +1,264 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import pytest
from tests.parser.engine.conftest import make_mock_tokenizer
from tests.parser.engine.streaming_helpers import (
collect_function_name,
collect_tool_arguments,
simulate_tool_streaming,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionToolsParam,
FunctionDefinition,
)
from vllm.parser.minimax_m2 import (
THINK_END,
TOOL_CALL_END,
TOOL_CALL_START,
MinimaxM2Parser,
)
@pytest.fixture
def mock_tokenizer():
return make_mock_tokenizer(
{
THINK_END: 99,
TOOL_CALL_START: 100,
TOOL_CALL_END: 101,
}
)
@pytest.fixture
def parser(mock_tokenizer):
return MinimaxM2Parser(mock_tokenizer)
def make_tools(*names: str):
return [
ChatCompletionToolsParam(
function=FunctionDefinition(
name=name,
parameters={
"type": "object",
"properties": {},
},
),
)
for name in names
]
class TestNonStreaming:
def test_no_tool_calls(self, parser, mock_request):
result = parser.extract_tool_calls(
"</think>This is a regular response without tool calls.",
mock_request,
)
assert result.tools_called is False
assert result.tool_calls == []
assert result.content == "This is a regular response without tool calls."
def test_single_tool_call(self, parser, mock_request):
result = parser.extract_tool_calls(
'<minimax:tool_call><invoke name="get_weather">'
'<parameter name="city">Seattle</parameter>'
"</invoke></minimax:tool_call>",
mock_request,
)
assert result.tools_called is True
assert len(result.tool_calls) == 1
assert result.tool_calls[0].function.name == "get_weather"
assert json.loads(result.tool_calls[0].function.arguments) == {
"city": "Seattle",
}
def test_multiple_invokes(self, parser, mock_request):
result = parser.extract_tool_calls(
"<minimax:tool_call>"
'<invoke name="search"><parameter name="q">OpenAI</parameter></invoke>'
'<invoke name="search"><parameter name="q">vLLM</parameter></invoke>'
"</minimax:tool_call>",
mock_request,
)
assert result.tools_called is True
assert [tc.function.name for tc in result.tool_calls] == ["search", "search"]
assert json.loads(result.tool_calls[0].function.arguments) == {"q": "OpenAI"}
assert json.loads(result.tool_calls[1].function.arguments) == {"q": "vLLM"}
def test_schema_type_coercion(self, mock_tokenizer, mock_request):
tools = [
ChatCompletionToolsParam(
function=FunctionDefinition(
name="forecast",
parameters={
"type": "object",
"properties": {
"days": {"type": "integer"},
"include_hourly": {"type": "boolean"},
},
},
),
)
]
parser = MinimaxM2Parser(mock_tokenizer, tools=tools)
mock_request.tools = tools
result = parser.extract_tool_calls(
'<minimax:tool_call><invoke name="forecast">'
'<parameter name="days">5</parameter>'
'<parameter name="include_hourly">true</parameter>'
"</invoke></minimax:tool_call>",
mock_request,
)
assert json.loads(result.tool_calls[0].function.arguments) == {
"days": 5,
"include_hourly": True,
}
def test_invalid_tool_name_is_rejected(self, mock_tokenizer, mock_request):
tools = make_tools("search")
parser = MinimaxM2Parser(mock_tokenizer)
mock_request.tools = tools
result = parser.extract_tool_calls(
'<minimax:tool_call><invoke name="img_gen">'
'<parameter name="prompt">a cat</parameter>'
"</invoke></minimax:tool_call>",
mock_request,
)
assert result.tools_called is False
assert result.tool_calls == []
def test_mixed_tool_names_only_return_valid(self, mock_tokenizer, mock_request):
tools = make_tools("search")
parser = MinimaxM2Parser(mock_tokenizer)
mock_request.tools = tools
result = parser.extract_tool_calls(
"<minimax:tool_call>"
'<invoke name="img_gen"><parameter name="prompt">cat</parameter></invoke>'
'<invoke name="search"><parameter name="query">news</parameter></invoke>'
"</minimax:tool_call>",
mock_request,
)
assert result.tools_called is True
assert [tc.function.name for tc in result.tool_calls] == ["search"]
assert json.loads(result.tool_calls[0].function.arguments) == {
"query": "news",
}
class TestStreaming:
def test_streaming_single_tool_call(self, parser, mock_request):
results = simulate_tool_streaming(
parser,
mock_request,
[
"<minimax:tool_call>",
'<invoke name="get_weather">',
'<parameter name="city">Seattle</parameter>',
"</invoke></minimax:tool_call>",
],
)
assert collect_function_name(results) == "get_weather"
assert json.loads(collect_tool_arguments(results)) == {
"city": "Seattle",
}
def test_streaming_multiple_invokes(self, parser, mock_request):
results = simulate_tool_streaming(
parser,
mock_request,
[
"<minimax:tool_call>",
'<invoke name="a"><parameter name="x">1</parameter></invoke>',
'<invoke name="b"><parameter name="y">2</parameter></invoke>',
"</minimax:tool_call>",
],
)
tool_names = [
tc.function.name
for delta, _ in results
if delta and delta.tool_calls
for tc in delta.tool_calls
if tc.function and tc.function.name
]
assert tool_names == ["a", "b"]
def test_streaming_invoke_prefix_split_before_quote(self, parser, mock_request):
results = simulate_tool_streaming(
parser,
mock_request,
[
"<minimax:tool_call>",
"<invoke name=",
'"get_weather">',
'<parameter name="city">Seattle</parameter>',
"</invoke></minimax:tool_call>",
],
)
assert collect_function_name(results) == "get_weather"
assert json.loads(collect_tool_arguments(results)) == {
"city": "Seattle",
}
def test_streaming_invalid_tool_name_is_rejected(
self, mock_tokenizer, mock_request
):
tools = make_tools("search")
parser = MinimaxM2Parser(mock_tokenizer)
mock_request.tools = tools
results = simulate_tool_streaming(
parser,
mock_request,
[
"<minimax:tool_call>",
'<invoke name="img_gen">',
'<parameter name="prompt">cat</parameter>',
"</invoke></minimax:tool_call>",
],
)
assert collect_function_name(results) is None
assert collect_tool_arguments(results) == ""
class TestReasoning:
def test_extract_reasoning_without_start_token(self, parser, mock_request):
reasoning, content = parser.extract_reasoning(
"This is reasoning</think>This is content",
mock_request,
)
assert reasoning == "This is reasoning"
assert content == "This is content"
def test_extract_reasoning_without_end_token(self, parser, mock_request):
reasoning, content = parser.extract_reasoning(
"This is still reasoning",
mock_request,
)
assert reasoning == "This is still reasoning"
assert content is None
def test_extract_content_ids_without_end_token(self, parser):
assert parser.extract_content_ids([1, 2, 3]) == []
def test_extract_content_ids_after_end_token(self, parser):
assert parser.extract_content_ids([1, 99, 2, 3]) == [2, 3]
+75
View File
@@ -30,6 +30,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
)
from vllm.parser.engine.registered_adapters import (
Gemma4Parser,
MinimaxM2Parser,
NemotronV3Parser,
Qwen3Parser,
)
@@ -393,6 +394,79 @@ def _build_qwen3(
return sample
# ── MiniMax M2 (XML invoke format, starts in REASONING) ──────────────
_MINIMAX_M2_VOCAB: dict[str, int] = {
"<think>": 50,
"</think>": 51,
"<minimax:tool_call>": 60,
"</minimax:tool_call>": 61,
}
def _minimax_m2_arg_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
def _minimax_m2_tool_segments(tool_calls: list[ToolCallSpec]) -> list[tuple[str, bool]]:
segs: list[tuple[str, bool]] = [("<minimax:tool_call>", True)]
for tc in tool_calls:
segs.append((f'<invoke name="{tc.name}">', False))
for key, value in tc.arguments.items():
segs.append(
(
f'<parameter name="{key}">'
f"{_minimax_m2_arg_value(value)}"
"</parameter>",
False,
)
)
segs.append(("</invoke>", False))
segs.append(("</minimax:tool_call>", True))
return segs
def _minimax_m2_segments(scenario: Scenario) -> list[tuple[str, bool]]:
segs: list[tuple[str, bool]] = []
if scenario.reasoning is not None:
segs.append((scenario.reasoning, False))
if scenario.content is not None or scenario.tool_calls:
segs.append(("</think>", True))
if scenario.content is not None:
segs.append((scenario.content, False))
if scenario.tool_calls:
segs.extend(_minimax_m2_tool_segments(scenario.tool_calls))
return segs
def _build_minimax_m2(scenario: Scenario, validate: bool = True) -> Sample:
expected_reasoning: str | None
if scenario.reasoning is not None:
expected_reasoning = scenario.reasoning.rstrip()
else:
expected_reasoning = ""
sample = _make_sample(
sample_id=f"minimax_m2-{scenario.id}",
description=scenario.description,
vocab=_MINIMAX_M2_VOCAB,
segments=_minimax_m2_segments(scenario),
expected_reasoning=expected_reasoning,
expected_content=_qwen3_expected_content(scenario),
expected_tool_calls=_expected_tc(scenario),
tools=_expected_tools(scenario),
)
if validate:
_validate_sample(sample, MinimaxM2Parser)
return sample
# ── Gemma4 (channel reasoning, custom arg format) ────────────────────
_GEMMA4_VOCAB: dict[str, int] = {
@@ -502,6 +576,7 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
_BUILDERS: dict[str, Any] = {
"qwen3": _build_qwen3,
"gemma4": _build_gemma4,
"minimax_m2": _build_minimax_m2,
"nemotron_v3": _build_nemotron_v3,
}
@@ -59,14 +59,6 @@ MULTIPLE_LINES = {
"is_reasoning_end": True,
}
# Case: only end token (empty reasoning, immediate response)
SHORTEST_REASONING_NO_STREAMING = {
"output": "</think>This is the response",
"reasoning": "",
"content": "This is the response",
"is_reasoning_end": True,
}
# Case: only end token streaming (reasoning is None because it's just the token)
SHORTEST_REASONING_STREAMING = {
"output": "</think>This is the response",
@@ -75,14 +67,6 @@ SHORTEST_REASONING_STREAMING = {
"is_reasoning_end": True,
}
# Case: empty output
EMPTY = {
"output": "",
"reasoning": "",
"content": None,
"is_reasoning_end": False,
}
# Case: empty streaming
EMPTY_STREAMING = {
"output": "",
@@ -149,21 +133,11 @@ TEST_CASES = [
MULTIPLE_LINES,
id="multiple_lines_streaming",
),
pytest.param(
False,
SHORTEST_REASONING_NO_STREAMING,
id="shortest_reasoning",
),
pytest.param(
True,
SHORTEST_REASONING_STREAMING,
id="shortest_reasoning_streaming",
),
pytest.param(
False,
EMPTY,
id="empty",
),
pytest.param(
True,
EMPTY_STREAMING,
@@ -18,7 +18,6 @@ pytestmark = pytest.mark.cpu_test
# Token IDs matching FakeTokenizer.vocab
TC_START_ID = 1
TC_END_ID = 2
EOS_ID = 99
class FakeTokenizer:
@@ -34,6 +33,10 @@ class FakeTokenizer:
def get_vocab(self):
return self.vocab
def decode(self, token_ids):
id_to_token = {v: k for k, v in self.vocab.items()}
return "".join(id_to_token.get(token_id, "") for token_id in token_ids)
@pytest.fixture
def parser():
@@ -121,7 +124,6 @@ class TestContentStreaming:
"""No tool call tokens — all text is streamed as content."""
results = _feed(parser, ["Hello ", "world"])
assert _collect_content(results) == "Hello world"
assert not parser.prev_tool_call_arr
def test_content_before_tool_call(self, parser):
"""Text before <minimax:tool_call> is streamed as content."""
@@ -135,7 +137,6 @@ class TestContentStreaming:
],
)
assert _collect_content(results) == "Let me check. "
assert len(parser.prev_tool_call_arr) == 1
def test_empty_delta_no_crash(self, parser):
"""Empty delta_text with no token IDs returns None."""
@@ -262,45 +263,6 @@ class TestMultipleInvokes:
assert tc[1]["name"] == "get_stock"
# ---------------------------------------------------------------------------
# Internal state: prev_tool_call_arr
# ---------------------------------------------------------------------------
class TestInternalState:
"""Verify prev_tool_call_arr is correct."""
def test_prev_tool_call_arr_single(self, parser):
_feed(
parser,
[
'<minimax:tool_call><invoke name="fn">'
'<parameter name="a">1</parameter>'
"</invoke></minimax:tool_call>",
],
)
assert len(parser.prev_tool_call_arr) == 1
assert parser.prev_tool_call_arr[0]["name"] == "fn"
assert parser.prev_tool_call_arr[0]["arguments"] == {"a": "1"}
def test_prev_tool_call_arr_multiple(self, parser):
"""prev_tool_call_arr records each invoke with correct arguments."""
_feed(
parser,
[
"<minimax:tool_call>",
'<invoke name="search"><parameter name="q">hello</parameter></invoke>',
'<invoke name="search"><parameter name="q">world</parameter></invoke>',
"</minimax:tool_call>",
],
)
assert len(parser.prev_tool_call_arr) == 2
assert parser.prev_tool_call_arr[0]["name"] == "search"
assert parser.prev_tool_call_arr[0]["arguments"] == {"q": "hello"}
assert parser.prev_tool_call_arr[1]["name"] == "search"
assert parser.prev_tool_call_arr[1]["arguments"] == {"q": "world"}
# ---------------------------------------------------------------------------
# DeltaMessage structure
# ---------------------------------------------------------------------------
@@ -324,7 +286,7 @@ class TestDeltaMessageFormat:
tc = tc_deltas[0]
assert tc.index == 0
assert tc.type == "function"
assert tc.id is not None and tc.id.startswith("call_")
assert tc.id is not None
assert tc.function.name == "fn"
assert json.loads(tc.function.arguments) == {"k": "v"}
@@ -344,72 +306,6 @@ class TestDeltaMessageFormat:
assert indices == [0, 1]
# ---------------------------------------------------------------------------
# Phase 3: EOS handling
# ---------------------------------------------------------------------------
class TestEOSHandling:
"""Tests for the end-of-stream phase."""
def test_eos_after_tool_calls(self, parser):
"""EOS token (empty delta, non-special token id) returns content=''."""
results = _feed(
parser,
[
"<minimax:tool_call>",
'<invoke name="fn"><parameter name="k">v</parameter></invoke>',
"</minimax:tool_call>",
# EOS: empty delta_text, non-special token id
("", [EOS_ID]),
],
)
# Last result should be the EOS empty-content signal
assert results[-1].content == ""
def test_end_token_ignored(self, parser):
"""</minimax:tool_call> special token should NOT trigger EOS."""
results = _feed(
parser,
[
"<minimax:tool_call>",
'<invoke name="fn"><parameter name="k">v</parameter></invoke>',
# </minimax:tool_call> arrives as special token
("", [TC_END_ID]),
],
)
# The tool call delta should be emitted, but no EOS signal
assert not any(r.content == "" and r.tool_calls is None for r in results)
# ---------------------------------------------------------------------------
# Start token detection via token IDs
# ---------------------------------------------------------------------------
class TestSpecialTokenDetection:
"""Start token arrives as a special token (not in delta_text)."""
def test_start_token_via_id(self, parser):
"""<minimax:tool_call> detected via delta_token_ids, not text."""
results = _feed(parser, ["Hello "])
assert _collect_content(results) == "Hello "
# Start token as special token (empty delta_text)
previous = "Hello "
result = parser.extract_tool_calls_streaming(
previous_text=previous,
current_text=previous,
delta_text="",
previous_token_ids=[],
current_token_ids=[],
delta_token_ids=[TC_START_ID],
request=None,
)
assert result is None # no content to emit
assert parser.is_tool_call_started is True
# ---------------------------------------------------------------------------
# Large chunks (stream_interval > 1)
# ---------------------------------------------------------------------------
@@ -419,7 +315,7 @@ class TestLargeChunks:
"""Simulate stream_interval > 1 where many tokens arrive at once."""
def test_header_and_params_in_separate_chunks(self, parser):
"""Header in chunk 1, all params + close in chunk 2, then EOS."""
"""Header in chunk 1, all params + close in chunk 2."""
chunk1 = '<minimax:tool_call><invoke name="get_weather">'
chunk2 = (
'<parameter name="city">Seattle</parameter>'
@@ -432,7 +328,6 @@ class TestLargeChunks:
[
chunk1,
chunk2,
("", [EOS_ID]),
],
)
@@ -441,12 +336,6 @@ class TestLargeChunks:
parsed = json.loads(tc[0]["arguments"])
assert parsed == {"city": "Seattle", "days": "5"}
assert len(parser.prev_tool_call_arr) == 1
assert parser.prev_tool_call_arr[0]["arguments"] == {
"city": "Seattle",
"days": "5",
}
class TestAnyOfNullableParam:
"""Regression: anyOf nullable parameter parsing (PR #32342)."""
+18 -5
View File
@@ -127,12 +127,14 @@ class IncrementalLexer:
def flush(self) -> list[LexToken]:
tokens: list[LexToken] = []
if self.buffer:
tokens.extend(self._drain(final=True))
if self.buffer:
tokens.append(LexToken(self.content_terminal, self.buffer))
self.buffer = ""
return tokens
def _drain(self) -> list[LexToken]:
def _drain(self, *, final: bool = False) -> list[LexToken]:
tokens: list[LexToken] = []
first_chars = self._literal_first_chars
content_terminal = self.content_terminal
@@ -161,11 +163,22 @@ class IncrementalLexer:
):
best_match = (name, lit, len(lit))
if self.buffer in prefix_set:
# If the current buffer is both a complete literal and the prefix
# of a longer literal, wait for the next chunk. For example,
# "<invoke name=" should not be emitted before the next chunk
# proves whether this is the quoted form '<invoke name="'.
if self.buffer in prefix_set and not final:
if best_match is not None:
tokens.append(LexToken(best_match[0], best_match[1]))
self.buffer = self.buffer[best_match[2] :]
continue
longer_match = False
for lit, _ in literals_by_first.get(first, ()):
if len(lit) > best_match[2] and lit.startswith(self.buffer):
longer_match = True
break
if not longer_match:
tokens.append(LexToken(best_match[0], best_match[1]))
self.buffer = self.buffer[best_match[2] :]
continue
break
else:
break
+16 -4
View File
@@ -30,6 +30,7 @@ from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
from vllm.tool_parsers.utils import (
coerce_to_schema_type,
extract_types_from_schema,
find_tool_name,
find_tool_properties,
)
@@ -319,15 +320,24 @@ class ParserEngine(Parser):
return json.dumps(args, ensure_ascii=False)
return args_json
def _is_valid_tool_name(self, name: str) -> bool:
if not self.parser_engine_config.validate_tool_names:
return True
if not self._tools:
return True
return find_tool_name(self._tools, name)
# ── Private helpers ─────────────────────────────────────────────
def _check_skip_tool_parsing(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> None:
tools = getattr(request, "tools", None)
if tools:
self._tools = tools
if not self.skip_tool_parsing:
tool_choice = getattr(request, "tool_choice", None)
tools = getattr(request, "tools", None)
if tool_choice == "none" and tools:
self.skip_tool_parsing = True
@@ -467,6 +477,7 @@ class ParserEngine(Parser):
output, this method starts the parser engine in ``CONTENT`` state
so it can parse content that has already had reasoning stripped.
"""
self._check_skip_tool_parsing(request)
_, parsed_content, tool_call_info = self._single_pass_parse(
content,
[],
@@ -586,6 +597,7 @@ class ParserEngine(Parser):
enable_auto_tools: bool = False,
model_output_token_ids: Sequence[int] = (),
) -> tuple[str | None, str | None, list[FunctionCall] | None]:
self._check_skip_tool_parsing(request)
reasoning, content, tool_call_info = self._single_pass_parse(
model_output,
model_output_token_ids,
@@ -705,7 +717,7 @@ class ParserEngine(Parser):
deltas: list[DeltaToolCall],
name: str | None,
) -> None:
if not name:
if not name or not self._is_valid_tool_name(name):
return
slot = self._tool_slots[idx]
slot.name = name
@@ -762,7 +774,7 @@ class ParserEngine(Parser):
if not slot.name_sent:
name = slot.name or self._try_extract_name(idx)
if name:
if name and self._is_valid_tool_name(name):
slot.name = name
slot.name_sent = True
self._ensure_tool_id(slot, name)
@@ -934,7 +946,7 @@ class ParserEngine(Parser):
else:
args_json = "{}"
if name:
if name and self._is_valid_tool_name(name):
self._ensure_tool_id(slot, name)
args_json = self._fix_arg_types(args_json, name)
tool_calls.append(
@@ -99,6 +99,9 @@ class ParserEngineConfig:
# .strip() content text when tool calls are present.
strip_content_whitespace_with_tools: bool = True
# Reject tool calls whose names are absent from the request tools.
validate_tool_names: bool = False
drop_tokens: frozenset[str] = field(default_factory=frozenset)
@cached_property
@@ -9,9 +9,15 @@ names so that :class:`ReasoningParserManager` and
from vllm.parser.engine.adapters import make_adapters
from vllm.parser.gemma4 import Gemma4Parser
from vllm.parser.minimax_m2 import MinimaxM2Parser
from vllm.parser.nemotron_v3 import NemotronV3Parser
from vllm.parser.qwen3 import Qwen3Parser
(
MinimaxM2ParserReasoningAdapter,
MinimaxM2ParserToolAdapter,
) = make_adapters(MinimaxM2Parser)
(
Gemma4ParserReasoningAdapter,
Gemma4ParserToolAdapter,
+180
View File
@@ -0,0 +1,180 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax M2 parser for XML-style tool calls.
MiniMax M2 tool call format::
<minimax:tool_call><invoke name="get_weather">
<parameter name="city">Seattle</parameter>
</invoke></minimax:tool_call>
Each ``<invoke>`` block becomes one tool call. The argument body consists
of ``<parameter name="...">...</parameter>`` tags.
"""
from __future__ import annotations
import functools
import json
import regex as re
from vllm.parser.engine.events import EventType
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
TOOL_CALL_START = "<minimax:tool_call>"
TOOL_CALL_END = "</minimax:tool_call>"
THINK_START = "<think>"
THINK_END = "</think>"
INVOKE_PREFIX_DQ = '<invoke name="'
INVOKE_PREFIX_SQ = "<invoke name='"
INVOKE_PREFIX_UNQUOTED = "<invoke name="
INVOKE_END = "</invoke>"
NAME_END_DQ = '">'
NAME_END_SQ = "'>"
NAME_END_UNQUOTED = ">"
_PARAM_RE = re.compile(
r"<\s*parameter\s+name\s*=\s*"
r"(?:\"(?P<dq_name>[^\"]*)\"|'(?P<sq_name>[^']*)'|(?P<bare_name>[^>\s]+))"
r"\s*>"
r"(?P<value>.*?)"
r"<\s*/\s*parameter\s*>",
re.DOTALL,
)
def _minimax_m2_arg_converter(raw_args: str, partial: bool) -> str:
params: dict[str, object] = {}
for match in _PARAM_RE.finditer(raw_args):
name = (
match.group("dq_name")
or match.group("sq_name")
or match.group("bare_name")
or ""
).strip()
if not name:
continue
params[name] = match.group("value").strip()
return json.dumps(params, ensure_ascii=False)
@functools.cache
def minimax_m2_config() -> ParserEngineConfig:
return ParserEngineConfig(
name="minimax_m2",
initial_state=ParserState.REASONING,
terminals={
"THINK_START": THINK_START,
"THINK_END": THINK_END,
"TOOL_START": TOOL_CALL_START,
"TOOL_END": TOOL_CALL_END,
"INVOKE_PREFIX_DQ": INVOKE_PREFIX_DQ,
"INVOKE_PREFIX_SQ": INVOKE_PREFIX_SQ,
"INVOKE_PREFIX_UNQUOTED": INVOKE_PREFIX_UNQUOTED,
"INVOKE_END": INVOKE_END,
"NAME_END_DQ": NAME_END_DQ,
"NAME_END_SQ": NAME_END_SQ,
"NAME_END_UNQUOTED": NAME_END_UNQUOTED,
},
token_id_terminals={
"THINK_START": THINK_START,
"THINK_END": THINK_END,
"TOOL_START": TOOL_CALL_START,
"TOOL_END": TOOL_CALL_END,
},
transitions={
(ParserState.REASONING, "THINK_START"): Transition(
ParserState.REASONING,
(),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
(ParserState.CONTENT, "THINK_END"): Transition(
ParserState.CONTENT,
(),
),
(ParserState.REASONING, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.REASONING_END,),
),
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(),
),
(ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
ParserState.CONTENT,
(),
),
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
ParserState.CONTENT,
(),
),
(ParserState.CONTENT, "TOOL_END"): Transition(
ParserState.CONTENT,
(),
),
(ParserState.TOOL_ARGS, "INVOKE_END"): Transition(
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
**{
(state, terminal): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
)
for state in (
ParserState.CONTENT,
ParserState.TOOL_PREAMBLE,
ParserState.TOOL_BETWEEN,
)
for terminal in (
"INVOKE_PREFIX_DQ",
"INVOKE_PREFIX_SQ",
"INVOKE_PREFIX_UNQUOTED",
)
},
**{
(ParserState.TOOL_NAME, terminal): Transition(
ParserState.TOOL_ARGS,
(),
)
for terminal in (
"NAME_END_DQ",
"NAME_END_SQ",
"NAME_END_UNQUOTED",
)
},
},
arg_converter=_minimax_m2_arg_converter,
stream_arg_deltas=True,
tool_args_json=False,
validate_tool_names=True,
)
class MinimaxM2Parser(ParserEngine):
"""MiniMax M2 parser backed by the declarative parser engine."""
def __init__(self, tokenizer, tools=None, **kwargs) -> None:
kwargs.setdefault("parser_engine_config", minimax_m2_config())
super().__init__(tokenizer, tools, **kwargs)
self._think_end_token_id = self.vocab.get(THINK_END)
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
end_id = self._think_end_token_id
if end_id is None:
return []
for i in range(len(input_ids) - 1, -1, -1):
if input_ids[i] == end_id:
return input_ids[i + 1 :]
return []
+2 -51
View File
@@ -8,8 +8,8 @@ from vllm.entrypoints.openai.engine.protocol import (
DeltaMessage,
)
from vllm.logger import init_logger
from vllm.parser.engine.registered_adapters import MinimaxM2ParserReasoningAdapter
from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
from vllm.tokenizers import TokenizerLike
if TYPE_CHECKING:
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser):
class MiniMaxM2ReasoningParser(MinimaxM2ParserReasoningAdapter): # type: ignore[valid-type, misc]
"""
Reasoning parser for MiniMax M2 model.
@@ -28,55 +28,6 @@ class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser):
actual response.
"""
@property
def start_token(self) -> str:
"""The token that starts reasoning content."""
return "<think>"
@property
def end_token(self) -> str:
"""The token that ends reasoning content."""
return "</think>"
def extract_reasoning_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
"""
Extract reasoning content from a delta message for streaming.
MiniMax M2 models don't generate <think> start token, so we assume
all content is reasoning until we encounter the </think> end token.
"""
# Skip single end token
if len(delta_token_ids) == 1 and delta_token_ids[0] == self.end_token_id:
return None
# Check if end token has already appeared in previous tokens
# meaning we're past the reasoning phase
if self.end_token_id in previous_token_ids:
# We're past the reasoning phase, this is content
return DeltaMessage(content=delta_text)
# Check if end token is in delta tokens
if self.end_token_id in delta_token_ids:
# End token in delta, split reasoning and content
end_index = delta_text.find(self.end_token)
reasoning = delta_text[:end_index]
content = delta_text[end_index + len(self.end_token) :]
return DeltaMessage(
reasoning=reasoning if reasoning else None,
content=content if content else None,
)
# No end token yet, all content is reasoning
return DeltaMessage(reasoning=delta_text)
class MiniMaxM2AppendThinkReasoningParser(ReasoningParser):
"""
+2 -278
View File
@@ -1,284 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import uuid
from collections.abc import Sequence
import regex as re
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.entrypoints.openai.engine.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
ExtractedToolCallInformation,
FunctionCall,
ToolCall,
)
from vllm.logger import init_logger
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import (
Tool,
ToolParser,
)
from vllm.tool_parsers.utils import (
coerce_to_schema_type,
extract_types_from_schema,
find_tool_properties,
)
logger = init_logger(__name__)
from vllm.parser.engine.registered_adapters import MinimaxM2ParserToolAdapter
class MinimaxM2ToolParser(ToolParser):
class MinimaxM2ToolParser(MinimaxM2ParserToolAdapter): # type: ignore[valid-type, misc]
structural_tag_model = "minimax"
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
super().__init__(tokenizer, tools)
self.prev_tool_call_arr: list[dict] = []
# Sentinel tokens
self.tool_call_start_token: str = "<minimax:tool_call>"
self.tool_call_end_token: str = "</minimax:tool_call>"
# Streaming state
self.is_tool_call_started: bool = False
self.current_tool_index: int = 0
# Regex patterns for complete parsing
self.tool_call_complete_regex = re.compile(
r"<minimax:tool_call>(.*?)</minimax:tool_call>", re.DOTALL
)
self.invoke_complete_regex = re.compile(
r"<invoke name=(.*?)</invoke>", re.DOTALL
)
self.parameter_complete_regex = re.compile(
r"<parameter name=(.*?)</parameter>", re.DOTALL
)
if not self.model_tokenizer:
raise ValueError(
"The model tokenizer must be passed to the ToolParser "
"constructor during construction."
)
self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None:
raise RuntimeError(
"MiniMax M2 Tool parser could not locate tool call start/end "
"tokens in the tokenizer!"
)
logger.debug(
"vLLM Successfully import tool parser %s !", self.__class__.__name__
)
def _generate_tool_call_id(self) -> str:
"""Generate a unique tool call ID."""
return f"call_{uuid.uuid4().hex[:24]}"
def _extract_name(self, name_str: str) -> str:
"""Extract name from quoted string."""
name_str = name_str.strip()
if (name_str.startswith('"') and name_str.endswith('"')) or (
name_str.startswith("'") and name_str.endswith("'")
):
return name_str[1:-1]
return name_str
def _parse_single_invoke(
self, invoke_str: str, tools: list | None
) -> ToolCall | None:
"""Parse a single <invoke> block."""
# Extract function name
name_match = re.search(r"^([^>]+)", invoke_str)
if not name_match:
return None
function_name = self._extract_name(name_match.group(1))
tool_properties = find_tool_properties(tools, function_name)
# Extract parameters
param_dict = {}
for match in self.parameter_complete_regex.findall(invoke_str):
param_match = re.search(r"^([^>]+)>(.*)", match, re.DOTALL)
if param_match:
param_name = self._extract_name(param_match.group(1))
param_value = param_match.group(2).strip()
param_types = extract_types_from_schema(
tool_properties.get(param_name, {})
)
param_dict[param_name] = coerce_to_schema_type(param_value, param_types)
return ToolCall(
type="function",
function=FunctionCall(
name=function_name,
arguments=json.dumps(param_dict, ensure_ascii=False),
),
)
def _extract_delta_tool_calls(
self,
current_text: str,
request: ChatCompletionRequest | None,
) -> list[DeltaToolCall]:
"""Extract DeltaToolCalls from newly completed <invoke> blocks.
Tracks progress via ``current_tool_index`` so each block is
extracted exactly once across successive streaming calls.
"""
complete_invokes = self.invoke_complete_regex.findall(current_text)
delta_tool_calls: list[DeltaToolCall] = []
while len(complete_invokes) > self.current_tool_index:
invoke_str = complete_invokes[self.current_tool_index]
tool_call = self._parse_single_invoke(
invoke_str,
self.tools,
)
if not tool_call:
self.current_tool_index += 1
continue
args_json = tool_call.function.arguments
idx = self.current_tool_index
self.current_tool_index += 1
self.prev_tool_call_arr.append(
{
"name": tool_call.function.name,
"arguments": json.loads(args_json),
}
)
self.streamed_args_for_tool.append(args_json)
delta_tool_calls.append(
DeltaToolCall(
index=idx,
id=self._generate_tool_call_id(),
function=DeltaFunctionCall(
name=tool_call.function.name,
arguments=args_json,
),
type="function",
)
)
return delta_tool_calls
def extract_tool_calls(
self,
model_output: str,
request: ChatCompletionRequest,
) -> ExtractedToolCallInformation:
"""Extract tool calls from complete model output (non-streaming)."""
# Quick check
if self.tool_call_start_token not in model_output:
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
try:
tool_calls = []
# Find all complete tool_call blocks
for tool_call_match in self.tool_call_complete_regex.findall(model_output):
# Find all invokes within this tool_call
for invoke_match in self.invoke_complete_regex.findall(tool_call_match):
tool_call = self._parse_single_invoke(invoke_match, self.tools)
if tool_call:
tool_calls.append(tool_call)
if not tool_calls:
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
# Update prev_tool_call_arr
self.prev_tool_call_arr.clear()
for tool_call in tool_calls:
self.prev_tool_call_arr.append(
{
"name": tool_call.function.name,
"arguments": tool_call.function.arguments,
}
)
# Extract content before first tool call
first_tool_idx = model_output.find(self.tool_call_start_token)
content = model_output[:first_tool_idx] if first_tool_idx > 0 else None
return ExtractedToolCallInformation(
tools_called=True, tool_calls=tool_calls, content=content
)
except Exception:
logger.exception("Error extracting tool calls")
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
def extract_tool_calls_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int], # pylint: disable=unused-argument
current_token_ids: Sequence[int], # pylint: disable=unused-argument
delta_token_ids: Sequence[int],
request: ChatCompletionRequest,
) -> DeltaMessage | None:
"""Extract tool calls from streaming model output.
Uses a buffer-until-complete-invoke strategy: tokens are buffered
until a complete ``<invoke>...</invoke>`` block is available, then
parsed and emitted in one shot.
"""
start_in_text = self.tool_call_start_token in delta_text
start_in_ids = self.tool_call_start_token_id in delta_token_ids
tool_call_starting = start_in_text or start_in_ids
# Reset state on new request (parser is reused) or new tool-call block.
if not previous_text or tool_call_starting:
self.current_tool_index = 0
self.prev_tool_call_arr.clear()
self.streamed_args_for_tool.clear()
self.is_tool_call_started = tool_call_starting
# Pass through content before any tool call.
if not self.is_tool_call_started:
return DeltaMessage(content=delta_text) if delta_text else None
# Capture content before the start token.
content_before = None
if start_in_text:
before = delta_text[: delta_text.index(self.tool_call_start_token)]
content_before = before or None
# Extract newly completed <invoke> blocks as DeltaToolCalls.
delta_tool_calls = self._extract_delta_tool_calls(current_text, request)
if delta_tool_calls or content_before:
return DeltaMessage(
content=content_before,
tool_calls=delta_tool_calls,
)
# EOS and </minimax:tool_call> both arrive as special tokens with
# no decoded text. Return non-None for EOS so the serving framework
# reaches the finish-reason handling path instead of skipping.
if (
not delta_text
and delta_token_ids
and self.prev_tool_call_arr
and self.tool_call_end_token_id not in delta_token_ids
):
return DeltaMessage(content="")
return None
+16
View File
@@ -182,6 +182,22 @@ def find_tool_properties(
return {}
def find_tool_name(
tools: list[Tool] | None,
tool_name: str,
) -> bool:
"""Return whether a function tool with *tool_name* exists."""
if not tools:
return False
for tool in tools:
if not _is_function_tool(tool):
continue
name, _ = _extract_tool_info(tool)
if name == tool_name:
return True
return False
def _get_tool_schema_from_tool(tool: Tool) -> dict:
name, params = _extract_tool_info(tool)
params = params if params else {"type": "object", "properties": {}}