From 5145e35e50ca7fa2bf381eaacdb30e234d774d1c Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Tue, 6 Oct 2026 13:46:27 +0800 Subject: [PATCH] [Frontend] Port the MiniMax M3 tool parser to the parser engine (#59743) Signed-off-by: Bugen Zhao Co-authored-by: Claude Opus 5.5 --- tests/parser/engine/test_minimax_m3.py | 253 +++++++++++++++ tests/parser/engine/test_replay.py | 9 +- tests/parser/engine/trace_builder.py | 62 ++++ .../test_minimax_m3_tool_parser.py | 57 ++-- vllm/parser/engine/registered_adapters.py | 6 + vllm/parser/minimax_m3.py | 293 ++++++++++++++++++ vllm/tool_parsers/minimax_m3_tool_parser.py | 19 +- 7 files changed, 658 insertions(+), 41 deletions(-) create mode 100644 tests/parser/engine/test_minimax_m3.py create mode 100644 vllm/parser/minimax_m3.py diff --git a/tests/parser/engine/test_minimax_m3.py b/tests/parser/engine/test_minimax_m3.py new file mode 100644 index 0000000000..48c65eeadb --- /dev/null +++ b/tests/parser/engine/test_minimax_m3.py @@ -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}" + + +def tool_block(*invokes: tuple[str, str]) -> str: + body = "\n".join( + f'{NS}{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 & c\n") + element("user_id", " 7 ") + assert parse_arguments(parser, mock_request, params) == { + "note": "\n a & 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}a{NS}", + f"{NS}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_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}")] + 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 diff --git a/tests/parser/engine/test_replay.py b/tests/parser/engine/test_replay.py index 89791ba29b..e6339f8959 100644 --- a/tests/parser/engine/test_replay.py +++ b/tests/parser/engine/test_replay.py @@ -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 diff --git a/tests/parser/engine/trace_builder.py b/tests/parser/engine/trace_builder.py index 3fa6e05078..284ff7048d 100644 --- a/tests/parser/engine/trace_builder.py +++ b/tests/parser/engine/trace_builder.py @@ -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}" + for key, item in value.items() + ) + if isinstance(value, list): + return "".join( + f"{ns}{_minimax_m3_value(item)}{ns}" 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}' + f"{_minimax_m3_value(tc.arguments)}{ns}\n" + for tc in scenario.tool_calls + ) + segs.append((f"{ns}\n{invokes}{ns}", 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, diff --git a/tests/tool_parsers/test_minimax_m3_tool_parser.py b/tests/tool_parsers/test_minimax_m3_tool_parser.py index 575892217a..c01ab89d1e 100644 --- a/tests/tool_parsers/test_minimax_m3_tool_parser.py +++ b/tests/tool_parsers/test_minimax_m3_tool_parser.py @@ -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}' @@ -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 diff --git a/vllm/parser/engine/registered_adapters.py b/vllm/parser/engine/registered_adapters.py index 31295869c5..6d610ce3b5 100644 --- a/vllm/parser/engine/registered_adapters.py +++ b/vllm/parser/engine/registered_adapters.py @@ -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, diff --git a/vllm/parser/minimax_m3.py b/vllm/parser/minimax_m3.py new file mode 100644 index 0000000000..dafe1457e9 --- /dev/null +++ b/vllm/parser/minimax_m3.py @@ -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[>[ + ]<]minimax[>[ + ]<]minimax[>[42]<]minimax[>[ + ]<]minimax[>[ + ]<]minimax[>[]<]minimax[>[book-001]<]minimax[>[]<]minimax[>[ + ]<]minimax[>[ + ]<]minimax[>[ + ]<]minimax[>[ + +Each ```` 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 ````). Arguments are emitted once the whole ```` 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_END = f"{NAMESPACE}" +INVOKE_PREFIX_DQ = f'{NAMESPACE}' +NAME_END_SQ = "'>" +NAME_END_UNQUOTED = ">" +ELEMENT_START = f"{NAMESPACE}<" +ELEMENT_END_START = f"{NAMESPACE} 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 ```` 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 ``: 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) diff --git a/vllm/tool_parsers/minimax_m3_tool_parser.py b/vllm/tool_parsers/minimax_m3_tool_parser.py index a8628448c4..6be0687273 100644 --- a/vllm/tool_parsers/minimax_m3_tool_parser.py +++ b/vllm/tool_parsers/minimax_m3_tool_parser.py @@ -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 ```` tags in one wrapper, - and represents nested arguments with parameter-name XML tags. - """ - - rust_parser_name = "MinimaxM3ToolParser" - tool_call_start_token = "]<]minimax[>[" +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