diff --git a/docs/features/tool_calling.md b/docs/features/tool_calling.md index fb70c50248..6527eccd09 100644 --- a/docs/features/tool_calling.md +++ b/docs/features/tool_calling.md @@ -458,6 +458,17 @@ Supported models: Flags: `--tool-call-parser qwen3_xml` +### MiMo-V2.6 Models (`mimo`) + +Supported models: + +* `XiaomiMiMo/MiMo-V2.6-Pro-RL` +* `XiaomiMiMo/MiMo-V2.6-Flash-RL` + +Use the checkpoint's chat template with `--tool-call-parser mimo --reasoning-parser mimo --enable-auto-tool-choice`. +For schema-constrained tool arguments, set `strict: true` inside each tool's `function`, or use `--tool-strict-level parameter`. +The parser supports `auto`, `required`, named tools, and `parallel_tool_calls=False`, preserving leading/trailing whitespace in string arguments. + ### Olmo 3 Models (`olmo3`) Olmo 3 models output tool calls in a format that is very similar to the one expected by the `pythonic` parser (see below), with a few differences. Each tool call is a pythonic string, but the parallel tool calls are newline-delimited, and the calls are wrapped within XML tags as `..`. In addition, the parser also allows JSON boolean and null literals (`true`, `false`, and `null`) in addition to the pythonic ones (`True`, `False`, and `None`). diff --git a/requirements/common.txt b/requirements/common.txt index ab0060284c..1958173fd7 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -26,7 +26,7 @@ lm-format-enforcer == 0.11.3 llguidance >= 1.7.0, < 1.8.0; platform_machine == "x86_64" or platform_machine == "arm64" or platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "s390x" outlines_core == 0.2.14 lark == 1.2.2 -xgrammar == 0.2.7; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le" +xgrammar == 0.2.8; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le" typing_extensions >= 4.10 filelock >= 3.16.1 # need to contain https://github.com/tox-dev/filelock/pull/317 partial-json-parser # used for parsing partial JSON outputs diff --git a/requirements/rocm.txt b/requirements/rocm.txt index 824e2e1201..85b196e401 100644 --- a/requirements/rocm.txt +++ b/requirements/rocm.txt @@ -24,7 +24,7 @@ timm>=1.0.17 amd-quark==0.12.post1 tilelang==0.1.10 # Required apache-tvm-ffi matching tilelang version -apache-tvm-ffi==0.1.10 +apache-tvm-ffi==0.1.11 # Required for faster safetensors model loading fastsafetensors >= 0.3.3 # Mooncake transfer engine for KV cache offloading/transfer (ROCm build, published on PyPI) diff --git a/requirements/test/cpu.txt b/requirements/test/cpu.txt index ab3e42886c..e8dd282121 100644 --- a/requirements/test/cpu.txt +++ b/requirements/test/cpu.txt @@ -1319,7 +1319,7 @@ word2number==1.1 # via lm-eval wrapt==1.17.2 # via smart-open -xgrammar==0.2.7 +xgrammar==0.2.8 # via -r requirements/test/../common.txt xxhash==3.5.0 # via diff --git a/requirements/test/cuda.txt b/requirements/test/cuda.txt index ffbc639c4c..b7711a10cf 100644 --- a/requirements/test/cuda.txt +++ b/requirements/test/cuda.txt @@ -1429,7 +1429,7 @@ word2number==1.1 # via lm-eval wrapt==1.17.2 # via smart-open -xgrammar==0.2.7 +xgrammar==0.2.8 # via # -c requirements/common.txt # -r requirements/test/../common.txt diff --git a/requirements/test/rocm.txt b/requirements/test/rocm.txt index 36e1ba182d..3581e5fea9 100644 --- a/requirements/test/rocm.txt +++ b/requirements/test/rocm.txt @@ -43,7 +43,7 @@ anyio==4.13.0 # sse-starlette # starlette # watchfiles -apache-tvm-ffi==0.1.10 +apache-tvm-ffi==0.1.11 # via # -c requirements/rocm.txt # tilelang @@ -1380,7 +1380,7 @@ word2number==1.1 # via lm-eval wrapt==2.1.2 # via smart-open -xgrammar==0.2.7 +xgrammar==0.2.8 # via # -c requirements/common.txt # -r requirements/test/../common.txt diff --git a/requirements/test/xpu.txt b/requirements/test/xpu.txt index e5f898adcc..2bcd3664c1 100644 --- a/requirements/test/xpu.txt +++ b/requirements/test/xpu.txt @@ -1218,7 +1218,7 @@ werkzeug==3.1.8 # via schemathesis word2number==1.1 # via lm-eval -xgrammar==0.2.7 +xgrammar==0.2.8 # via # -c requirements/common.txt # -r requirements/test/../common.txt diff --git a/tests/parser/engine/test_qwen3.py b/tests/parser/engine/test_qwen3.py index 07a025876a..7bdddb2faf 100644 --- a/tests/parser/engine/test_qwen3.py +++ b/tests/parser/engine/test_qwen3.py @@ -1175,3 +1175,27 @@ class TestNestedSchemaCoercion: assert questions[0]["question"] == "Pick a color" assert questions[0]["multiSelect"] is False assert questions[0]["answer"] is None + + +@pytest.mark.parametrize("value", ["\n北京 &\n", "null", "42", "", "a\nb"]) +@pytest.mark.parametrize("chunk_size", [1, 7, 1000]) +def test_mimo_preserves_verbatim_parameter_values( + mock_tokenizer, mock_request, value, chunk_size +): + from vllm.parser.mimo import MiMoParser + + text = ( + f"{value}" + "" + ) + parser = MiMoParser(mock_tokenizer, chat_template_kwargs={"enable_thinking": False}) + result = parser.extract_tool_calls(text, mock_request) + assert json.loads(result.tool_calls[0].function.arguments) == {"text": value} + parser = MiMoParser(mock_tokenizer, chat_template_kwargs={"enable_thinking": False}) + results = simulate_tool_streaming( + parser, + mock_request, + [text[i : i + chunk_size] for i in range(0, len(text), chunk_size)], + ) + assert collect_function_name(results) == "run" + assert json.loads(collect_tool_arguments(results)) == {"text": value} diff --git a/tests/parser/engine/trace_builder.py b/tests/parser/engine/trace_builder.py index 29b9db3f2b..321ce6b9e0 100644 --- a/tests/parser/engine/trace_builder.py +++ b/tests/parser/engine/trace_builder.py @@ -15,6 +15,7 @@ from __future__ import annotations import functools import json +from collections.abc import Callable from dataclasses import dataclass, replace from typing import Any @@ -39,6 +40,7 @@ from vllm.parser.engine.registered_adapters import ( GraniteThinkingParser, InklingParser, KimiK2Parser, + MiMoParser, MinimaxM2Parser, NemotronV3Parser, Plamo3Parser, @@ -356,7 +358,12 @@ def _qwen3_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]: ] -def _qwen3_segments(scenario: Scenario) -> list[tuple[str, bool]]: +def _qwen3_segments( + scenario: Scenario, + tool_segments: Callable[[ToolCallSpec], list[tuple[str, bool]]] = ( + _qwen3_tool_segments + ), +) -> list[tuple[str, bool]]: segs: list[tuple[str, bool]] = [] if scenario.reasoning is not None: segs.append((scenario.reasoning, False)) @@ -369,7 +376,7 @@ def _qwen3_segments(scenario: Scenario) -> list[tuple[str, bool]]: segs.append((scenario.content, False)) if scenario.tool_calls: for tc in scenario.tool_calls: - segs.extend(_qwen3_tool_segments(tc)) + segs.extend(tool_segments(tc)) return segs @@ -389,6 +396,9 @@ def _build_qwen3( parser_cls: type = Qwen3Parser, strip_trailing_ws: bool = False, validate: bool = True, + tool_segments: Callable[[ToolCallSpec], list[tuple[str, bool]]] = ( + _qwen3_tool_segments + ), ) -> Sample: expected_reasoning: str | None if scenario.reasoning is not None: @@ -403,7 +413,7 @@ def _build_qwen3( sample_id=f"{name}-{scenario.id}", description=scenario.description, vocab=_QWEN3_VOCAB, - segments=_qwen3_segments(scenario), + segments=_qwen3_segments(scenario, tool_segments), expected_reasoning=expected_reasoning, expected_content=_qwen3_expected_content(scenario), expected_tool_calls=_expected_tc(scenario), @@ -414,6 +424,31 @@ def _build_qwen3( return sample +# ── MiMo (compact Qwen3 XML, no newlines between tags) ─────────────── + + +def _mimo_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]: + parts = [f""] + for key, value in tc.arguments.items(): + parts.append(f"{_qwen3_arg_value(value)}") + parts.append("") + return [ + ("", True), + ("".join(parts), False), + ("", True), + ] + + +def _build_mimo(scenario: Scenario, validate: bool = True) -> Sample: + return _build_qwen3( + scenario, + name="mimo", + parser_cls=MiMoParser, + validate=validate, + tool_segments=_mimo_tool_segments, + ) + + # ── MiniMax M2 (XML invoke format, starts in REASONING) ────────────── _MINIMAX_M2_VOCAB: dict[str, int] = { @@ -1232,6 +1267,7 @@ _BUILDERS: dict[str, Any] = { "glm47_moe": _build_glm47_moe, "kimi_k2": _build_kimi_k2, "qwen3": _build_qwen3, + "mimo": _build_mimo, "inkling": _build_inkling, "plamo3": _build_plamo3, } diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py index 99b18c6736..01bc53968e 100644 --- a/tests/tool_parsers/test_structural_tag_registry.py +++ b/tests/tool_parsers/test_structural_tag_registry.py @@ -40,6 +40,7 @@ from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser from vllm.tool_parsers.llama_tool_parser import Llama3JsonToolParser +from vllm.tool_parsers.mimo_tool_parser import MiMoToolParser from vllm.tool_parsers.minimax_m2_tool_parser import MinimaxM2ToolParser from vllm.tool_parsers.plamo3_engine_tool_parser import Plamo3EngineToolParser from vllm.tool_parsers.qwen3_engine_tool_parser import Qwen3EngineToolParser @@ -665,6 +666,7 @@ def test_get_model_structural_tag_supports_named_tool_choice( (Llama3JsonToolParser, "llama"), (MinimaxM2ToolParser, "minimax"), (Qwen3EngineToolParser, "qwen_3_coder"), + (MiMoToolParser, "mimo"), ], ) def test_tool_parsers_declare_matching_xgrammar_builtin_model(parser_cls, model): @@ -727,7 +729,7 @@ def test_get_structural_tag_disables_reasoning( @pytest.mark.parametrize( - "parser_cls", [Qwen3EngineToolParser, DeepSeekV41EngineToolParser] + "parser_cls", [Qwen3EngineToolParser, DeepSeekV41EngineToolParser, MiMoToolParser] ) def test_unified_parser_get_structural_tag_disables_reasoning( parser_cls, @@ -1167,3 +1169,78 @@ def test_tool_strict_level_from_name(): ToolStrictLevel.from_name("strict") with pytest.raises(ValueError, match="expected one of auto, function, parameter"): ToolStrictLevel.from_name("off") + + +@pytest.mark.parametrize("policy", ["auto", "required", "named"]) +def test_mimo_strict_compact_xml(policy): + tools = [ + ChatCompletionToolsParam( + type="function", + function={ + "name": "run", + "strict": True, + "parameters": { + "type": "object", + "properties": {"n": {"type": "integer", "minimum": 1}}, + "required": ["n"], + "additionalProperties": False, + }, + }, + ) + ] + choice = ( + ChatCompletionNamedToolChoiceParam( + function=ChatCompletionNamedFunction(name="run") + ) + if policy == "named" + else policy + ) + tag = get_model_structural_tag("mimo", tools, choice, reasoning=False) + grammar = Grammar.from_structural_tag(tag) + call = "2" + assert _is_grammar_accept_string(grammar, call) + assert _is_grammar_accept_string(grammar, call * 2) == (policy != "named") + assert _is_grammar_accept_string(grammar, "answer") == (policy == "auto") + for invalid in [ + call.replace("function=run", "function=unknown"), + call.replace("2", ""), + call.replace(">2", ">bad"), + call.replace(">2", ">0"), + call.replace("", ""), + call.replace("", "3"), + "plan" + call, + ]: + assert not _is_grammar_accept_string(grammar, invalid) + tools[0].function.strict = False + relaxed = Grammar.from_structural_tag( + get_model_structural_tag("mimo", tools, "required", reasoning=False) + ) + assert _is_grammar_accept_string( + relaxed, call.replace("", "") + ) + + +@pytest.mark.parametrize("policy", ["auto", "required"]) +def test_mimo_request_limits_parallel_calls(sample_tools_strict, policy): + from vllm.parser.parser_manager import ParserManager + + request = ChatCompletionRequest( + model="mimo", + messages=[], + tools=[t.model_dump() for t in sample_tools_strict], + tool_choice=policy, + parallel_tool_calls=False, + ) + parser_cls = ParserManager.get_parser( + tool_parser_name="mimo", enable_auto_tools=True + ) + parser = parser_cls(MagicMock(), tools=sample_tools_strict) + adjusted = parser.adjust_request(request) + grammar = Grammar.from_structural_tag(adjusted.structured_outputs.structural_tag) + call = ( + "北京" + "" + ) + assert _is_grammar_accept_string(grammar, call) + assert not _is_grammar_accept_string(grammar, call * 2) + assert not _is_grammar_accept_string(grammar, call + "extra text") diff --git a/vllm/parser/engine/registered_adapters.py b/vllm/parser/engine/registered_adapters.py index 7ce123c787..3c445c44c9 100644 --- a/vllm/parser/engine/registered_adapters.py +++ b/vllm/parser/engine/registered_adapters.py @@ -17,6 +17,7 @@ from vllm.parser.granite import GraniteParser from vllm.parser.granite_thinking import GraniteThinkingParser 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.mistral import MistralParser from vllm.parser.nemotron_v3 import NemotronV3Parser @@ -69,6 +70,11 @@ from vllm.parser.seed_oss import SeedOssParser Qwen3ParserToolAdapter, ) = make_adapters(Qwen3Parser) +( + MiMoParserReasoningAdapter, + MiMoParserToolAdapter, +) = make_adapters(MiMoParser) + ( SeedOssParserReasoningAdapter, SeedOssParserToolAdapter, diff --git a/vllm/parser/mimo.py b/vllm/parser/mimo.py new file mode 100644 index 0000000000..b1c37dd746 --- /dev/null +++ b/vllm/parser/mimo.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""MiMo's compact XML parameters contain verbatim string values.""" + +import json +from dataclasses import replace + +from vllm.parser.qwen3 import ( + _PARAM_RE, + _PARTIAL_PARAM_RE, + CHATML_TURN_BOUNDARIES, + Qwen3Parser, + qwen3_config, +) + + +def _mimo_arg_converter(raw_args: str, partial: bool) -> str: + params = {m.group(1): m.group(2) for m in _PARAM_RE.finditer(raw_args)} + if partial: + match = _PARTIAL_PARAM_RE.search(_PARAM_RE.sub("", raw_args)) + if match and match.group(1): + params[match.group(1)] = match.group(2) + return json.dumps(params, ensure_ascii=False) + + +class MiMoParser(Qwen3Parser): + def __init__(self, tokenizer, tools=None, **kwargs): + thinking = (kwargs.get("chat_template_kwargs") or {}).get( + "enable_thinking", True + ) + kwargs.setdefault( + "parser_engine_config", + replace( + qwen3_config( + thinking=thinking, + name="mimo", + turn_boundary_tokens=CHATML_TURN_BOUNDARIES, + ), + arg_converter=_mimo_arg_converter, + ), + ) + super().__init__(tokenizer, tools, **kwargs) diff --git a/vllm/reasoning/__init__.py b/vllm/reasoning/__init__.py index f1ab60b6b7..736a4fcdeb 100644 --- a/vllm/reasoning/__init__.py +++ b/vllm/reasoning/__init__.py @@ -109,8 +109,8 @@ _REASONING_PARSERS_TO_REGISTER = { "K2HorizonReasoningParser", ), "mimo": ( - "qwen3_engine_reasoning_parser", - "Qwen3ParserReasoningAdapter", + "mimo_engine_reasoning_parser", + "MiMoParserReasoningAdapter", ), "minimax_m2": ( "minimax_m2_reasoning_parser", diff --git a/vllm/reasoning/mimo_engine_reasoning_parser.py b/vllm/reasoning/mimo_engine_reasoning_parser.py new file mode 100644 index 0000000000..d07cde9278 --- /dev/null +++ b/vllm/reasoning/mimo_engine_reasoning_parser.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import MiMoParserReasoningAdapter + +__all__ = ["MiMoParserReasoningAdapter"] diff --git a/vllm/tool_parsers/__init__.py b/vllm/tool_parsers/__init__.py index 90a724858a..5647a75291 100644 --- a/vllm/tool_parsers/__init__.py +++ b/vllm/tool_parsers/__init__.py @@ -143,8 +143,8 @@ _TOOL_PARSERS_TO_REGISTER = { "LongcatFlashToolParser", ), "mimo": ( - "qwen3_engine_tool_parser", - "Qwen3EngineToolParser", + "mimo_tool_parser", + "MiMoToolParser", ), "minimax_m2": ( "minimax_m2_tool_parser", diff --git a/vllm/tool_parsers/mimo_tool_parser.py b/vllm/tool_parsers/mimo_tool_parser.py new file mode 100644 index 0000000000..cca77eb56b --- /dev/null +++ b/vllm/tool_parsers/mimo_tool_parser.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from xgrammar.structural_tag import TriggeredTagsFormat + +from vllm.parser.engine.registered_adapters import MiMoParserToolAdapter +from vllm.tool_parsers.tool_strict_level import ToolStrictLevel + + +class MiMoToolParser(MiMoParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = "mimo" + + def get_structural_tag( + self, request, *, reasoning=False, strict_level=ToolStrictLevel.AUTO + ): + tag = super().get_structural_tag( + request, reasoning=reasoning, strict_level=strict_level + ) + if ( + tag is not None + and request.parallel_tool_calls is False + and isinstance(tag.format, TriggeredTagsFormat) + ): + tag.format.stop_after_first = True + return tag diff --git a/vllm/tool_parsers/structural_tag_registry.py b/vllm/tool_parsers/structural_tag_registry.py index fdb4e4f92a..bc8fab8fe6 100644 --- a/vllm/tool_parsers/structural_tag_registry.py +++ b/vllm/tool_parsers/structural_tag_registry.py @@ -66,6 +66,7 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset( "deepseek_v3_1", "qwen_3_5", "qwen_3_coder", + "mimo", "qwen_3", "deepseek_v3_2", "glm_4_7",