[Frontend] Port the MiniMax M3 tool parser to the parser engine (#59743)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bugen Zhao
2026-10-06 05:46:27 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6fddc9b21e
commit 5145e35e50
7 changed files with 658 additions and 41 deletions
+253
View File
@@ -0,0 +1,253 @@
# 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_content,
collect_function_name,
collect_tool_arguments,
simulate_tool_streaming,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionToolsParam,
FunctionDefinition,
)
from vllm.parser.minimax_m3 import (
INVOKE_END,
MAX_ELEMENT_DEPTH,
NAMESPACE,
TOOL_CALL_END,
TOOL_CALL_START,
MinimaxM3Parser,
)
NS = NAMESPACE
TOOLS = [
ChatCompletionToolsParam(
function=FunctionDefinition(
name="create_order",
parameters={
"type": "object",
"properties": {
"user_id": {"type": "integer"},
"note": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"shipping": {
"type": "object",
"properties": {"zip": {"type": "integer"}},
},
"either": {
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "string"},
]
},
"free": {"type": "object"},
},
},
),
)
]
@pytest.fixture
def parser():
return MinimaxM3Parser(make_mock_tokenizer({}), tools=TOOLS)
def element(name: str, body: str) -> str:
return f"{NS}<{name}>{body}{NS}</{name}>"
def tool_block(*invokes: tuple[str, str]) -> str:
body = "\n".join(
f'{NS}<invoke name="{name}">{params}{INVOKE_END}' for name, params in invokes
)
return f"{TOOL_CALL_START}\n{body}\n{TOOL_CALL_END}"
def parse_arguments(parser, request, params: str, name: str = "create_order"):
result = parser.extract_tool_calls(tool_block((name, params)), request)
assert [tc.function.name for tc in result.tool_calls] == [name]
return json.loads(result.tool_calls[0].function.arguments)
class TestArguments:
def test_nested_elements_follow_schema(self, parser, mock_request):
params = (
element("tags", element("item", "a") + element("item", "b"))
+ element("shipping", element("zip", "018956"))
+ element("either", element("item", "1") + element("item", "2"))
+ element("free", element("t", "x") + element("t", "y"))
)
assert parse_arguments(parser, mock_request, params) == {
"tags": ["a", "b"],
"shipping": {"zip": 18956},
"either": [1, 2],
"free": {"t": ["x", "y"]},
}
def test_empty_elements_follow_schema(self, parser, mock_request):
# The chat template renders [] and {} as empty elements.
params = (
element("tags", "")
+ element("shipping", "")
+ element("note", "")
+ element("either", "")
)
assert parse_arguments(parser, mock_request, params) == {
"tags": [],
"shipping": {},
"note": "",
"either": "",
}
def test_unknown_tool_keeps_text_and_nests_objects(self, parser, mock_request):
params = element("n", "1") + element(
"list", element("item", "a") + element("item", "b")
)
assert parse_arguments(parser, mock_request, params, name="other") == {
"n": "1",
"list": {"item": ["a", "b"]},
}
def test_mixed_text_uses_reserved_field(self, parser, mock_request):
params = element("free", "hi " + element("k", "v") + " there") + element(
"shipping", "x" + element("$text", "child")
)
assert parse_arguments(parser, mock_request, params) == {
"free": {"k": "v", "$text": "hi there"},
"shipping": {"$text": "child", "$$text": "x"},
}
def test_leaf_text_is_verbatim_for_strings(self, parser, mock_request):
params = element("note", "\n a <b> & c\n") + element("user_id", " 7 ")
assert parse_arguments(parser, mock_request, params) == {
"note": "\n a <b> & c\n",
"user_id": 7,
}
def test_block_marker_named_element_is_argument(self, parser, mock_request):
params = element("free", element("tool_call", "v"))
assert parse_arguments(parser, mock_request, params) == {
"free": {"tool_call": "v"},
}
@pytest.mark.parametrize(
"tail",
[
"junk" + element("note", "dropped"),
f"{NS}<note>a{NS}</other>",
f"{NS}<note>unterminated",
],
)
def test_malformed_element_keeps_preceding_params(self, parser, mock_request, tail):
params = element("user_id", "1") + tail
assert parse_arguments(parser, mock_request, params) == {"user_id": 1}
def test_depth_limit(self, parser, mock_request):
def nested(depth: int) -> str:
body = "v"
for _ in range(depth - 1):
body = element("n", body)
return element("free", body)
accepted = parse_arguments(parser, mock_request, nested(MAX_ELEMENT_DEPTH))
assert "free" in accepted
rejected = parse_arguments(parser, mock_request, nested(MAX_ELEMENT_DEPTH + 1))
assert rejected == {}
class TestToolBlock:
def test_multiple_invokes_and_prefix_content(self, parser, mock_request):
result = parser.extract_tool_calls(
"Checking.\n"
+ tool_block(
("create_order", element("user_id", "1")),
("create_order", element("user_id", "2")),
),
mock_request,
)
assert result.content == "Checking.\n"
assert [json.loads(tc.function.arguments) for tc in result.tool_calls] == [
{"user_id": 1},
{"user_id": 2},
]
def test_text_after_tool_block_is_dropped(self, parser, mock_request):
result = parser.extract_tool_calls(
"pre" + tool_block(("create_order", "")) + "post", mock_request
)
assert result.content == "pre"
assert len(result.tool_calls) == 1
def test_invoke_outside_tool_block_is_content(self, parser, mock_request):
text = f'{NS}<invoke name="create_order">{INVOKE_END}'
result = parser.extract_tool_calls(text, mock_request)
assert not result.tools_called
assert result.content == text
class TestStreaming:
def test_arguments_arrive_with_invoke_end(self, parser, mock_request):
block = tool_block(
("create_order", element("user_id", "42") + element("tags", ""))
)
invoke_end = block.index(INVOKE_END)
results = simulate_tool_streaming(
parser,
mock_request,
["Sure. ", block[:invoke_end], block[invoke_end:]],
)
before_end, at_end = results[1][0], results[2][0]
assert before_end is not None and before_end.tool_calls
assert before_end.tool_calls[0].function.name == "create_order"
assert not before_end.tool_calls[0].function.arguments
assert collect_content(results) == "Sure. "
assert json.loads(collect_tool_arguments([(at_end, "")])) == {
"user_id": 42,
"tags": [],
}
def test_char_chunks_match_complete_parse(self, parser, mock_request):
text = "a" + tool_block(
("create_order", element("shipping", element("zip", "1"))),
("create_order", element("note", "x")),
)
results = simulate_tool_streaming(parser, mock_request, list(text))
assert collect_content(results) == "a"
assert collect_function_name(results) == "create_order"
streamed = [
tc.function.arguments
for delta, _ in results
if delta and delta.tool_calls
for tc in delta.tool_calls
if tc.function and tc.function.arguments
]
assert [json.loads(args) for args in streamed] == [
{"shipping": {"zip": 1}},
{"note": "x"},
]
def test_truncated_invoke_keeps_completed_params(self, parser, mock_request):
block = tool_block(
("create_order", element("user_id", "1") + element("note", "x"))
)
truncated = block[: block.index(f"{NS}</note>")]
results = simulate_tool_streaming(parser, mock_request, [truncated])
results.append((parser.finish_streaming(), truncated))
assert collect_function_name(results) == "create_order"
assert json.loads(collect_tool_arguments(results)) == {"user_id": 1}
def test_adjust_request_keeps_special_tokens_skipped(parser, mock_request):
mock_request.skip_special_tokens = True
assert parser.adjust_request(mock_request).skip_special_tokens is True
+5 -4
View File
@@ -74,10 +74,11 @@ def _discover_parsers() -> list[_ParserInfo]:
if cfg.name not in _BUILDERS:
missing_builders.append(f"{obj.__name__} (config.name={cfg.name!r})")
continue
if cfg.name in ("inkling", "plamo3"):
# Inkling uses typed structural blocks. PLaMo markers can span
# several token IDs. Neither fits this TOOL_END-token harness;
# their direct and delegating coverage lives in dedicated tests.
if cfg.name in ("inkling", "plamo3", "minimax_m3"):
# Inkling uses typed structural blocks. PLaMo and MiniMax M3
# markers can span several token IDs. None fits this TOOL_END-token
# harness; their direct and delegating coverage lives in dedicated
# tests.
continue
if cfg.name == "granite":
# Granite has a JSON-array tool body with no TOOL_END terminal, so
+62
View File
@@ -42,6 +42,7 @@ from vllm.parser.engine.registered_adapters import (
KimiK2Parser,
MiMoParser,
MinimaxM2Parser,
MinimaxM3Parser,
NemotronV3Parser,
Plamo3Parser,
Qwen3Parser,
@@ -539,6 +540,66 @@ def _build_minimax_m2(scenario: Scenario, validate: bool = True) -> Sample:
return sample
# ── MiniMax M3 (namespaced XML elements, no reasoning) ───────────────
_MINIMAX_M3_NS = "]<]minimax[>["
def _minimax_m3_value(value: Any) -> str:
"""Render a value the way the M3 chat template's ``to_xml`` does."""
ns = _MINIMAX_M3_NS
if isinstance(value, dict):
return "".join(
f"{ns}<{key}>{_minimax_m3_value(item)}{ns}</{key}>"
for key, item in value.items()
)
if isinstance(value, list):
return "".join(
f"{ns}<item>{_minimax_m3_value(item)}{ns}</item>" for item in value
)
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _minimax_m3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
ns = _MINIMAX_M3_NS
segs: list[tuple[str, bool]] = []
if scenario.content:
# The tool parser receives reasoning-stripped content.
segs.append((scenario.content, False))
if scenario.tool_calls:
invokes = "".join(
f'{ns}<invoke name="{tc.name}">'
f"{_minimax_m3_value(tc.arguments)}{ns}</invoke>\n"
for tc in scenario.tool_calls
)
segs.append((f"{ns}<tool_call>\n{invokes}{ns}</tool_call>", False))
return segs
def _minimax_m3_expected_content(scenario: Scenario) -> str | None:
if scenario.tool_calls and not (scenario.content or "").strip():
return None
return scenario.content
def _build_minimax_m3(scenario: Scenario, validate: bool = True) -> Sample:
sample = _make_sample(
sample_id=f"minimax_m3-{scenario.id}",
description=scenario.description,
vocab={},
segments=_minimax_m3_segments(scenario),
expected_reasoning=None,
expected_content=_minimax_m3_expected_content(scenario),
expected_tool_calls=_expected_tc(scenario),
tools=_expected_tools(scenario),
)
if validate:
_validate_sample(sample, MinimaxM3Parser)
return sample
# ── Gemma4 (channel reasoning, custom arg format) ────────────────────
_GEMMA4_VOCAB: dict[str, int] = {
@@ -1275,6 +1336,7 @@ _BUILDERS: dict[str, Any] = {
"gemma4": _build_gemma4,
"granite": _build_granite,
"minimax_m2": _build_minimax_m2,
"minimax_m3": _build_minimax_m3,
"nemotron_v3": _build_nemotron_v3,
"granite_thinking_parser": _build_granite_thinking,
"seed_oss": _build_seed_oss,
@@ -8,16 +8,13 @@ import pytest
from vllm.entrypoints.generate.base.protocol import DeltaMessage
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
ChatCompletionToolsParam,
FunctionDefinition,
)
from vllm.tool_parsers import ToolParserManager
from vllm.tool_parsers.minimax_m3_tool_parser import MinimaxM3ToolParser
# MinimaxM3ToolParser extends RustToolParser; skip when the PyO3 extension
# is absent (mirrors the guard in test_rust_tool_parser.py).
pytest.importorskip("vllm._rust_tool_parser")
pytestmark = [pytest.mark.cpu_test, pytest.mark.skip_global_cleanup]
NS = "]<]minimax[>["
@@ -108,6 +105,23 @@ def build_order_call() -> str:
)
ORDER_ARGUMENTS = {
"user_id": 42,
"urgent": True,
"note": "Please leave at front desk.",
"shipping": {"city": "Singapore", "zip": 18956},
"items": [
{"sku": "book-001", "qty": 2},
{"sku": "pen-007", "qty": 5},
],
"metadata": {
"source": "mobile",
"campaign": "may-launch",
},
"duplicate_demo": {"tag": ["a", "b"]},
}
def build_order_invocation(user_id: int) -> str:
return (
f'{NS}<invoke name="create_order">'
@@ -181,6 +195,20 @@ def test_minimax_m3_parser_registered():
assert ToolParserManager.get_tool_parser("minimax_m3") is MinimaxM3ToolParser
def test_required_tool_choice_keeps_native_syntax(parser):
request = ChatCompletionRequest(
model="minimax-m3",
messages=[{"role": "user", "content": "order"}],
tools=sample_tools(),
tool_choice="required",
)
adjusted = parser.adjust_request(request)
assert adjusted.structured_outputs is None
assert adjusted.skip_special_tokens
def test_non_streaming_nested_tool_call(parser):
result = parser.extract_tool_calls(
"I will create it.\n" + build_order_call(),
@@ -192,21 +220,7 @@ def test_non_streaming_nested_tool_call(parser):
assert len(result.tool_calls) == 1
tool_call = result.tool_calls[0]
assert tool_call.function.name == "create_order"
assert json.loads(tool_call.function.arguments) == {
"user_id": 42,
"urgent": True,
"note": "Please leave at front desk.",
"shipping": {"city": "Singapore", "zip": 18956},
"items": [
{"sku": "book-001", "qty": 2},
{"sku": "pen-007", "qty": 5},
],
"metadata": {
"source": "mobile",
"campaign": "may-launch",
},
"duplicate_demo": {"tag": ["a", "b"]},
}
assert json.loads(tool_call.function.arguments) == ORDER_ARGUMENTS
def test_non_streaming_without_tool_call_keeps_content(parser):
@@ -258,8 +272,5 @@ def test_streaming_nested_tool_call(parser):
assert len(tool_calls) == 1
assert tool_calls[0]["name"] == "create_order"
assert tool_calls[0]["id"] is not None
assert json.loads(tool_calls[0]["arguments"]) == json.loads(
parser.streamed_args_for_tool[0]
)
assert json.loads(parser.prev_tool_call_arr[0]["arguments"])["items"][1]["qty"] == 5
assert json.loads(tool_calls[0]["arguments"]) == ORDER_ARGUMENTS
assert results[-1].content is None
@@ -19,6 +19,7 @@ from vllm.parser.inkling import InklingParser
from vllm.parser.kimi_k2 import KimiK2Parser
from vllm.parser.mimo import MiMoParser
from vllm.parser.minimax_m2 import MinimaxM2Parser
from vllm.parser.minimax_m3 import MinimaxM3Parser
from vllm.parser.mistral import MistralParser
from vllm.parser.nemotron_v3 import NemotronV3Parser
from vllm.parser.plamo3 import Plamo3Parser
@@ -46,6 +47,11 @@ from vllm.parser.step3p5 import Step3p5Parser
MinimaxM2ParserToolAdapter,
) = make_adapters(MinimaxM2Parser)
(
MinimaxM3ParserReasoningAdapter,
MinimaxM3ParserToolAdapter,
) = make_adapters(MinimaxM3Parser)
(
Gemma4ParserReasoningAdapter,
Gemma4ParserToolAdapter,
+293
View File
@@ -0,0 +1,293 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax M3 parser for namespace-delimited XML-style tool calls.
MiniMax M3 prefixes every structural tag with the ``]<]minimax[>[`` marker::
]<]minimax[>[<tool_call>
]<]minimax[>[<invoke name="create_order">
]<]minimax[>[<user_id>42]<]minimax[>[</user_id>
]<]minimax[>[<items>
]<]minimax[>[<item>]<]minimax[>[<sku>book-001]<]minimax[>[</sku>]<]minimax[>[</item>
]<]minimax[>[</items>
]<]minimax[>[</invoke>
]<]minimax[>[</tool_call>
Each ``<invoke>`` becomes one tool call. Parameters are elements named after
the argument and may nest: an element holding child elements becomes an
object, or an array when its schema says so (the chat template renders list
items as ``<item>``). Arguments are emitted once the whole ``<invoke>`` is
parsed. Reasoning is handled by ``MiniMaxM3ReasoningParser``.
"""
from __future__ import annotations
import functools
import json
from typing import TYPE_CHECKING, Any
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,
)
from vllm.tool_parsers.utils import (
coerce_to_schema_type,
extract_types_from_schema,
find_tool_properties,
)
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import Tool
NAMESPACE = "]<]minimax[>["
TOOL_CALL_START = f"{NAMESPACE}<tool_call>"
TOOL_CALL_END = f"{NAMESPACE}</tool_call>"
INVOKE_PREFIX_DQ = f'{NAMESPACE}<invoke name="'
INVOKE_PREFIX_SQ = f"{NAMESPACE}<invoke name='"
INVOKE_PREFIX_UNQUOTED = f"{NAMESPACE}<invoke name="
INVOKE_END = f"{NAMESPACE}</invoke>"
NAME_END_DQ = '">'
NAME_END_SQ = "'>"
NAME_END_UNQUOTED = ">"
ELEMENT_START = f"{NAMESPACE}<"
ELEMENT_END_START = f"{NAMESPACE}</"
MIXED_TEXT_FIELD = "$text"
MAX_ELEMENT_DEPTH = 128
# An element value: its text, or its ``(name, value)`` children in order.
_Element = str | list[tuple[str, "_Element"]]
def _parse_element(text: str, pos: int, depth: int) -> tuple[str, _Element, int] | None:
"""Parse the element starting at ``pos``; ``None`` if malformed."""
if depth > MAX_ELEMENT_DEPTH:
return None
name_start = pos + len(ELEMENT_START)
name_end = text.find(">", name_start)
if name_end <= name_start:
return None
name = text[name_start:name_end]
if name.startswith("/") or not name.strip():
return None
close_tag = f"{ELEMENT_END_START}{name}>"
pos = name_end + 1
text_parts: list[str] = []
children: list[tuple[str, _Element]] = []
while True:
marker = text.find(NAMESPACE, pos)
if marker < 0:
return None
text_parts.append(text[pos:marker])
if text.startswith(close_tag, marker):
pos = marker + len(close_tag)
break
if not text.startswith(ELEMENT_START, marker):
return None
child = _parse_element(text, marker, depth + 1)
if child is None:
return None
child_name, child_value, pos = child
children.append((child_name, child_value))
body_text = "".join(text_parts)
if not children:
return name, body_text, pos
if body_text.strip():
# Keep mixed text under a reserved field that no child uses.
field = MIXED_TEXT_FIELD
while any(child_name == field for child_name, _ in children):
field = "$" + field
children.append((field, body_text))
return name, children, pos
def _parse_invoke_params(raw_args: str) -> list[tuple[str, _Element]]:
"""Parse the parameter elements of one ``<invoke>`` body.
Parsing stops at the first malformed or unterminated element, keeping the
parameters before it.
"""
params: list[tuple[str, _Element]] = []
pos = 0
while True:
while pos < len(raw_args) and raw_args[pos].isspace():
pos += 1
if not raw_args.startswith(ELEMENT_START, pos):
return params
element = _parse_element(raw_args, pos, depth=1)
if element is None:
return params
name, value, pos = element
params.append((name, value))
def _container_schema(schema: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
"""Return the first ``array`` or ``object`` alternative of ``schema``."""
types = schema.get("type")
for kind in ("array", "object"):
if types == kind or (isinstance(types, list) and kind in types):
return kind, schema
if types is None:
if "items" in schema:
return "array", schema
if "properties" in schema or "additionalProperties" in schema:
return "object", schema
for choice_field in ("anyOf", "oneOf"):
for choice in schema.get(choice_field) or ():
if isinstance(choice, dict) and (found := _container_schema(choice)):
return found
return None
def _convert_element(value: _Element, schema: Any) -> Any:
if not isinstance(schema, dict):
schema = None
container = _container_schema(schema) if schema is not None else None
if isinstance(value, str):
if schema is None:
return value
types = extract_types_from_schema(schema)
# The template renders an empty list or dict as an empty element.
if container is not None and "string" not in types and not value.strip():
return [] if container[0] == "array" else {}
return coerce_to_schema_type(value, types)
if container is not None and container[0] == "array":
items = container[1].get("items")
return [_convert_element(child, items) for _, child in value]
return _convert_object(value, container[1] if container is not None else None)
def _convert_object(
elements: list[tuple[str, _Element]], schema: dict[str, Any] | None
) -> dict[str, Any]:
"""Convert named elements to an object; repeated names collect into a list."""
properties = (schema or {}).get("properties") or {}
additional = (schema or {}).get("additionalProperties")
result: dict[str, Any] = {}
repeated: set[str] = set()
for name, element in elements:
converted = _convert_element(element, properties.get(name, additional))
if name not in result:
result[name] = converted
elif name in repeated:
result[name].append(converted)
else:
result[name] = [result[name], converted]
repeated.add(name)
return result
def _minimax_m3_arg_converter(
raw_args: str,
partial: bool,
properties: dict[str, Any] | None = None,
) -> str:
params = _parse_invoke_params(raw_args)
arguments = _convert_object(params, {"properties": properties or {}})
return json.dumps(arguments, ensure_ascii=False)
@functools.cache
def minimax_m3_config() -> ParserEngineConfig:
return ParserEngineConfig(
name="minimax_m3",
initial_state=ParserState.CONTENT,
terminals={
"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,
},
transitions={
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(),
),
**{
(state, terminal): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
)
for state in (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")
},
# Nested parameter elements are argument text; only the invoke
# closer ends the call.
(ParserState.TOOL_ARGS, "INVOKE_END"): Transition(
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
# The tool block ends the message: stay in TOOL_BETWEEN so any
# text after it is dropped.
(ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
ParserState.TOOL_BETWEEN,
(),
),
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
ParserState.TOOL_BETWEEN,
(),
),
},
arg_converter=_minimax_m3_arg_converter,
# Emit arguments once per `<invoke>`: converted values are not
# prefix-stable while elements arrive, e.g. a repeated parameter name
# turns an already streamed scalar into a list.
stream_arg_deltas=False,
tool_args_json=False,
strip_content_whitespace_with_tools=False,
)
class MinimaxM3Parser(ParserEngine):
"""MiniMax M3 tool-call parser backed by the declarative parser engine."""
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
**kwargs,
) -> None:
kwargs.setdefault("parser_engine_config", minimax_m3_config())
super().__init__(tokenizer, tools, **kwargs)
self._arg_converter = self._convert_args
def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
# The M3 markers are non-special added tokens, so they decode with
# special tokens skipped.
return request
def _convert_args(self, raw_args: str, partial: bool) -> str:
func_name = next(
(slot.name for slot in self._tool_slots if slot.args == raw_args), ""
)
properties = find_tool_properties(self._tools, func_name.strip())
return _minimax_m3_arg_converter(raw_args, partial, properties)
+5 -14
View File
@@ -1,19 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.tool_parsers.rust_tool_parser import RustToolParser
from vllm.parser.engine.registered_adapters import MinimaxM3ParserToolAdapter
class MinimaxM3ToolParser(RustToolParser):
"""Adapter from the Rust MiniMax M3 parser to vLLM ToolParser.
The real M3 grammar lives in the Rust tool-parser crate. This class only
configures the generic Rust bridge with the MiniMax M3 parser name.
M3 is not M2 with renamed tags: it prefixes each structural tag with the
MiniMax namespace marker, allows multiple ``<invoke>`` tags in one wrapper,
and represents nested arguments with parameter-name XML tags.
"""
rust_parser_name = "MinimaxM3ToolParser"
tool_call_start_token = "]<]minimax[>[<tool_call>"
class MinimaxM3ToolParser(MinimaxM3ParserToolAdapter): # type: ignore[valid-type, misc]
# Required and named tool choices parse the native M3 syntax instead of
# forcing JSON output.
supports_required_and_named = False