[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:
co-authored by
Claude Opus 5.5
parent
6fddc9b21e
commit
5145e35e50
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user