[Frontend] Support strict MiMo-V2.6 tool calling (#58019)

Co-authored-by: Codex <noreply@openai.com> Co-authored-by: yuchuan <yuchuan.7streams@gmail.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Ubospica <ubospica@gmail.com> Signed-off-by: yuchuan <yuchuan.7streams@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Yixin Dong committed Sep 29, 2026 at 08:47 UTC dfc8e0f3e2ad7791ba32e2e88053cda064052208
17 files changed +243 -15
docs/features/tool_calling.md
+11
@@ -458,6 +458,17 @@ Supported models:
458
459 Flags: `--tool-call-parser qwen3_xml`
460
461 +### MiMo-V2.6 Models (`mimo`)
462 +
463 +Supported models:
464 +
465 +* `XiaomiMiMo/MiMo-V2.6-Pro-RL`
466 +* `XiaomiMiMo/MiMo-V2.6-Flash-RL`
467 +
468 +Use the checkpoint's chat template with `--tool-call-parser mimo --reasoning-parser mimo --enable-auto-tool-choice`.
469 +For schema-constrained tool arguments, set `strict: true` inside each tool's `function`, or use `--tool-strict-level parameter`.
470 +The parser supports `auto`, `required`, named tools, and `parallel_tool_calls=False`, preserving leading/trailing whitespace in string arguments.
471 +
472 ### Olmo 3 Models (`olmo3`)
473
474 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 `<function_calls>..</function_calls>`. 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`).
requirements/common.txt
+1 -1
@@ -26,7 +26,7 @@ lm-format-enforcer == 0.11.3
26 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"
27 outlines_core == 0.2.14
28 lark == 1.2.2
29 -xgrammar == 0.2.7; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le"
29 +xgrammar == 0.2.8; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le"
30 typing_extensions >= 4.10
31 filelock >= 3.16.1 # need to contain https://github.com/tox-dev/filelock/pull/317
32 partial-json-parser # used for parsing partial JSON outputs
requirements/rocm.txt
+1 -1
@@ -24,7 +24,7 @@ timm>=1.0.17
24 amd-quark==0.12.post1
25 tilelang==0.1.10
26 # Required apache-tvm-ffi matching tilelang version
27 -apache-tvm-ffi==0.1.10
27 +apache-tvm-ffi==0.1.11
28 # Required for faster safetensors model loading
29 fastsafetensors >= 0.3.3
30 # Mooncake transfer engine for KV cache offloading/transfer (ROCm build, published on PyPI)
requirements/test/cpu.txt
+1 -1
@@ -1319,7 +1319,7 @@ word2number==1.1
1319 # via lm-eval
1320 wrapt==1.17.2
1321 # via smart-open
1322 -xgrammar==0.2.7
1322 +xgrammar==0.2.8
1323 # via -r requirements/test/../common.txt
1324 xxhash==3.5.0
1325 # via
requirements/test/cuda.txt
+1 -1
@@ -1429,7 +1429,7 @@ word2number==1.1
1429 # via lm-eval
1430 wrapt==1.17.2
1431 # via smart-open
1432 -xgrammar==0.2.7
1432 +xgrammar==0.2.8
1433 # via
1434 # -c requirements/common.txt
1435 # -r requirements/test/../common.txt
requirements/test/rocm.txt
+2 -2
@@ -43,7 +43,7 @@ anyio==4.13.0
43 # sse-starlette
44 # starlette
45 # watchfiles
46 -apache-tvm-ffi==0.1.10
46 +apache-tvm-ffi==0.1.11
47 # via
48 # -c requirements/rocm.txt
49 # tilelang
@@ -1380,7 +1380,7 @@ word2number==1.1
1380 # via lm-eval
1381 wrapt==2.1.2
1382 # via smart-open
1383 -xgrammar==0.2.7
1383 +xgrammar==0.2.8
1384 # via
1385 # -c requirements/common.txt
1386 # -r requirements/test/../common.txt
requirements/test/xpu.txt
+1 -1
@@ -1218,7 +1218,7 @@ werkzeug==3.1.8
1218 # via schemathesis
1219 word2number==1.1
1220 # via lm-eval
1221 -xgrammar==0.2.7
1221 +xgrammar==0.2.8
1222 # via
1223 # -c requirements/common.txt
1224 # -r requirements/test/../common.txt
tests/parser/engine/test_qwen3.py
+24
@@ -1175,3 +1175,27 @@ class TestNestedSchemaCoercion:
1175 assert questions[0]["question"] == "Pick a color"
1176 assert questions[0]["multiSelect"] is False
1177 assert questions[0]["answer"] is None
1178 +
1179 +
1180 +@pytest.mark.parametrize("value", ["\n北京 &amp;\n", "null", "42", "", "a\nb"])
1181 +@pytest.mark.parametrize("chunk_size", [1, 7, 1000])
1182 +def test_mimo_preserves_verbatim_parameter_values(
1183 + mock_tokenizer, mock_request, value, chunk_size
1184 +):
1185 + from vllm.parser.mimo import MiMoParser
1186 +
1187 + text = (
1188 + f"<tool_call><function=run><parameter=text>{value}</parameter>"
1189 + "</function></tool_call>"
1190 + )
1191 + parser = MiMoParser(mock_tokenizer, chat_template_kwargs={"enable_thinking": False})
1192 + result = parser.extract_tool_calls(text, mock_request)
1193 + assert json.loads(result.tool_calls[0].function.arguments) == {"text": value}
1194 + parser = MiMoParser(mock_tokenizer, chat_template_kwargs={"enable_thinking": False})
1195 + results = simulate_tool_streaming(
1196 + parser,
1197 + mock_request,
1198 + [text[i : i + chunk_size] for i in range(0, len(text), chunk_size)],
1199 + )
1200 + assert collect_function_name(results) == "run"
1201 + assert json.loads(collect_tool_arguments(results)) == {"text": value}
tests/parser/engine/trace_builder.py
+39 -3
@@ -15,6 +15,7 @@ from __future__ import annotations
15
16 import functools
17 import json
18 +from collections.abc import Callable
19 from dataclasses import dataclass, replace
20 from typing import Any
21
@@ -39,6 +40,7 @@ from vllm.parser.engine.registered_adapters import (
40 GraniteThinkingParser,
41 InklingParser,
42 KimiK2Parser,
43 + MiMoParser,
44 MinimaxM2Parser,
45 NemotronV3Parser,
46 Plamo3Parser,
@@ -356,7 +358,12 @@ def _qwen3_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
358 ]
359
360
359 -def _qwen3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
361 +def _qwen3_segments(
362 + scenario: Scenario,
363 + tool_segments: Callable[[ToolCallSpec], list[tuple[str, bool]]] = (
364 + _qwen3_tool_segments
365 + ),
366 +) -> list[tuple[str, bool]]:
367 segs: list[tuple[str, bool]] = []
368 if scenario.reasoning is not None:
369 segs.append((scenario.reasoning, False))
@@ -369,7 +376,7 @@ def _qwen3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
376 segs.append((scenario.content, False))
377 if scenario.tool_calls:
378 for tc in scenario.tool_calls:
372 - segs.extend(_qwen3_tool_segments(tc))
379 + segs.extend(tool_segments(tc))
380 return segs
381
382
@@ -389,6 +396,9 @@ def _build_qwen3(
396 parser_cls: type = Qwen3Parser,
397 strip_trailing_ws: bool = False,
398 validate: bool = True,
399 + tool_segments: Callable[[ToolCallSpec], list[tuple[str, bool]]] = (
400 + _qwen3_tool_segments
401 + ),
402 ) -> Sample:
403 expected_reasoning: str | None
404 if scenario.reasoning is not None:
@@ -403,7 +413,7 @@ def _build_qwen3(
413 sample_id=f"{name}-{scenario.id}",
414 description=scenario.description,
415 vocab=_QWEN3_VOCAB,
406 - segments=_qwen3_segments(scenario),
416 + segments=_qwen3_segments(scenario, tool_segments),
417 expected_reasoning=expected_reasoning,
418 expected_content=_qwen3_expected_content(scenario),
419 expected_tool_calls=_expected_tc(scenario),
@@ -414,6 +424,31 @@ def _build_qwen3(
424 return sample
425
426
427 +# ── MiMo (compact Qwen3 XML, no newlines between tags) ───────────────
428 +
429 +
430 +def _mimo_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
431 + parts = [f"<function={tc.name}>"]
432 + for key, value in tc.arguments.items():
433 + parts.append(f"<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
434 + parts.append("</function>")
435 + return [
436 + ("<tool_call>", True),
437 + ("".join(parts), False),
438 + ("</tool_call>", True),
439 + ]
440 +
441 +
442 +def _build_mimo(scenario: Scenario, validate: bool = True) -> Sample:
443 + return _build_qwen3(
444 + scenario,
445 + name="mimo",
446 + parser_cls=MiMoParser,
447 + validate=validate,
448 + tool_segments=_mimo_tool_segments,
449 + )
450 +
451 +
452 # ── MiniMax M2 (XML invoke format, starts in REASONING) ──────────────
453
454 _MINIMAX_M2_VOCAB: dict[str, int] = {
@@ -1232,6 +1267,7 @@ _BUILDERS: dict[str, Any] = {
1267 "glm47_moe": _build_glm47_moe,
1268 "kimi_k2": _build_kimi_k2,
1269 "qwen3": _build_qwen3,
1270 + "mimo": _build_mimo,
1271 "inkling": _build_inkling,
1272 "plamo3": _build_plamo3,
1273 }
tests/tool_parsers/test_structural_tag_registry.py
+78 -1
@@ -40,6 +40,7 @@ from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
40 from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser
41 from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser
42 from vllm.tool_parsers.llama_tool_parser import Llama3JsonToolParser
43 +from vllm.tool_parsers.mimo_tool_parser import MiMoToolParser
44 from vllm.tool_parsers.minimax_m2_tool_parser import MinimaxM2ToolParser
45 from vllm.tool_parsers.plamo3_engine_tool_parser import Plamo3EngineToolParser
46 from vllm.tool_parsers.qwen3_engine_tool_parser import Qwen3EngineToolParser
@@ -665,6 +666,7 @@ def test_get_model_structural_tag_supports_named_tool_choice(
666 (Llama3JsonToolParser, "llama"),
667 (MinimaxM2ToolParser, "minimax"),
668 (Qwen3EngineToolParser, "qwen_3_coder"),
669 + (MiMoToolParser, "mimo"),
670 ],
671 )
672 def test_tool_parsers_declare_matching_xgrammar_builtin_model(parser_cls, model):
@@ -727,7 +729,7 @@ def test_get_structural_tag_disables_reasoning(
729
730
731 @pytest.mark.parametrize(
730 - "parser_cls", [Qwen3EngineToolParser, DeepSeekV41EngineToolParser]
732 + "parser_cls", [Qwen3EngineToolParser, DeepSeekV41EngineToolParser, MiMoToolParser]
733 )
734 def test_unified_parser_get_structural_tag_disables_reasoning(
735 parser_cls,
@@ -1167,3 +1169,78 @@ def test_tool_strict_level_from_name():
1169 ToolStrictLevel.from_name("strict")
1170 with pytest.raises(ValueError, match="expected one of auto, function, parameter"):
1171 ToolStrictLevel.from_name("off")
1172 +
1173 +
1174 +@pytest.mark.parametrize("policy", ["auto", "required", "named"])
1175 +def test_mimo_strict_compact_xml(policy):
1176 + tools = [
1177 + ChatCompletionToolsParam(
1178 + type="function",
1179 + function={
1180 + "name": "run",
1181 + "strict": True,
1182 + "parameters": {
1183 + "type": "object",
1184 + "properties": {"n": {"type": "integer", "minimum": 1}},
1185 + "required": ["n"],
1186 + "additionalProperties": False,
1187 + },
1188 + },
1189 + )
1190 + ]
1191 + choice = (
1192 + ChatCompletionNamedToolChoiceParam(
1193 + function=ChatCompletionNamedFunction(name="run")
1194 + )
1195 + if policy == "named"
1196 + else policy
1197 + )
1198 + tag = get_model_structural_tag("mimo", tools, choice, reasoning=False)
1199 + grammar = Grammar.from_structural_tag(tag)
1200 + call = "<tool_call><function=run><parameter=n>2</parameter></function></tool_call>"
1201 + assert _is_grammar_accept_string(grammar, call)
1202 + assert _is_grammar_accept_string(grammar, call * 2) == (policy != "named")
1203 + assert _is_grammar_accept_string(grammar, "answer") == (policy == "auto")
1204 + for invalid in [
1205 + call.replace("function=run", "function=unknown"),
1206 + call.replace("<parameter=n>2</parameter>", ""),
1207 + call.replace(">2</parameter>", ">bad</parameter>"),
1208 + call.replace(">2</parameter>", ">0</parameter>"),
1209 + call.replace("<parameter=n>", "<parameter=other>"),
1210 + call.replace("</function>", "<parameter=n>3</parameter></function>"),
1211 + "<think>plan</think>" + call,
1212 + ]:
1213 + assert not _is_grammar_accept_string(grammar, invalid)
1214 + tools[0].function.strict = False
1215 + relaxed = Grammar.from_structural_tag(
1216 + get_model_structural_tag("mimo", tools, "required", reasoning=False)
1217 + )
1218 + assert _is_grammar_accept_string(
1219 + relaxed, call.replace("<parameter=n>", "<parameter=other>")
1220 + )
1221 +
1222 +
1223 +@pytest.mark.parametrize("policy", ["auto", "required"])
1224 +def test_mimo_request_limits_parallel_calls(sample_tools_strict, policy):
1225 + from vllm.parser.parser_manager import ParserManager
1226 +
1227 + request = ChatCompletionRequest(
1228 + model="mimo",
1229 + messages=[],
1230 + tools=[t.model_dump() for t in sample_tools_strict],
1231 + tool_choice=policy,
1232 + parallel_tool_calls=False,
1233 + )
1234 + parser_cls = ParserManager.get_parser(
1235 + tool_parser_name="mimo", enable_auto_tools=True
1236 + )
1237 + parser = parser_cls(MagicMock(), tools=sample_tools_strict)
1238 + adjusted = parser.adjust_request(request)
1239 + grammar = Grammar.from_structural_tag(adjusted.structured_outputs.structural_tag)
1240 + call = (
1241 + "<tool_call><function=get_weather><parameter=city>北京</parameter>"
1242 + "</function></tool_call>"
1243 + )
1244 + assert _is_grammar_accept_string(grammar, call)
1245 + assert not _is_grammar_accept_string(grammar, call * 2)
1246 + assert not _is_grammar_accept_string(grammar, call + "extra text")
vllm/parser/engine/registered_adapters.py
+6
@@ -17,6 +17,7 @@ from vllm.parser.granite import GraniteParser
17 from vllm.parser.granite_thinking import GraniteThinkingParser
18 from vllm.parser.inkling import InklingParser
19 from vllm.parser.kimi_k2 import KimiK2Parser
20 +from vllm.parser.mimo import MiMoParser
21 from vllm.parser.minimax_m2 import MinimaxM2Parser
22 from vllm.parser.mistral import MistralParser
23 from vllm.parser.nemotron_v3 import NemotronV3Parser
@@ -69,6 +70,11 @@ from vllm.parser.seed_oss import SeedOssParser
70 Qwen3ParserToolAdapter,
71 ) = make_adapters(Qwen3Parser)
72
73 +(
74 + MiMoParserReasoningAdapter,
75 + MiMoParserToolAdapter,
76 +) = make_adapters(MiMoParser)
77 +
78 (
79 SeedOssParserReasoningAdapter,
80 SeedOssParserToolAdapter,
vllm/parser/mimo.py new
+42
@@ -0,0 +1,42 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""MiMo's compact XML parameters contain verbatim string values."""
4 +
5 +import json
6 +from dataclasses import replace
7 +
8 +from vllm.parser.qwen3 import (
9 + _PARAM_RE,
10 + _PARTIAL_PARAM_RE,
11 + CHATML_TURN_BOUNDARIES,
12 + Qwen3Parser,
13 + qwen3_config,
14 +)
15 +
16 +
17 +def _mimo_arg_converter(raw_args: str, partial: bool) -> str:
18 + params = {m.group(1): m.group(2) for m in _PARAM_RE.finditer(raw_args)}
19 + if partial:
20 + match = _PARTIAL_PARAM_RE.search(_PARAM_RE.sub("", raw_args))
21 + if match and match.group(1):
22 + params[match.group(1)] = match.group(2)
23 + return json.dumps(params, ensure_ascii=False)
24 +
25 +
26 +class MiMoParser(Qwen3Parser):
27 + def __init__(self, tokenizer, tools=None, **kwargs):
28 + thinking = (kwargs.get("chat_template_kwargs") or {}).get(
29 + "enable_thinking", True
30 + )
31 + kwargs.setdefault(
32 + "parser_engine_config",
33 + replace(
34 + qwen3_config(
35 + thinking=thinking,
36 + name="mimo",
37 + turn_boundary_tokens=CHATML_TURN_BOUNDARIES,
38 + ),
39 + arg_converter=_mimo_arg_converter,
40 + ),
41 + )
42 + super().__init__(tokenizer, tools, **kwargs)
vllm/reasoning/__init__.py
+2 -2
@@ -109,8 +109,8 @@ _REASONING_PARSERS_TO_REGISTER = {
109 "K2HorizonReasoningParser",
110 ),
111 "mimo": (
112 - "qwen3_engine_reasoning_parser",
113 - "Qwen3ParserReasoningAdapter",
112 + "mimo_engine_reasoning_parser",
113 + "MiMoParserReasoningAdapter",
114 ),
115 "minimax_m2": (
116 "minimax_m2_reasoning_parser",
vllm/reasoning/mimo_engine_reasoning_parser.py new
+6
@@ -0,0 +1,6 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import MiMoParserReasoningAdapter
5 +
6 +__all__ = ["MiMoParserReasoningAdapter"]
vllm/tool_parsers/__init__.py
+2 -2
@@ -143,8 +143,8 @@ _TOOL_PARSERS_TO_REGISTER = {
143 "LongcatFlashToolParser",
144 ),
145 "mimo": (
146 - "qwen3_engine_tool_parser",
147 - "Qwen3EngineToolParser",
146 + "mimo_tool_parser",
147 + "MiMoToolParser",
148 ),
149 "minimax_m2": (
150 "minimax_m2_tool_parser",
vllm/tool_parsers/mimo_tool_parser.py new
+25
@@ -0,0 +1,25 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from xgrammar.structural_tag import TriggeredTagsFormat
5 +
6 +from vllm.parser.engine.registered_adapters import MiMoParserToolAdapter
7 +from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
8 +
9 +
10 +class MiMoToolParser(MiMoParserToolAdapter): # type: ignore[valid-type, misc]
11 + structural_tag_model = "mimo"
12 +
13 + def get_structural_tag(
14 + self, request, *, reasoning=False, strict_level=ToolStrictLevel.AUTO
15 + ):
16 + tag = super().get_structural_tag(
17 + request, reasoning=reasoning, strict_level=strict_level
18 + )
19 + if (
20 + tag is not None
21 + and request.parallel_tool_calls is False
22 + and isinstance(tag.format, TriggeredTagsFormat)
23 + ):
24 + tag.format.stop_after_first = True
25 + return tag
vllm/tool_parsers/structural_tag_registry.py
+1
@@ -66,6 +66,7 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
66 "deepseek_v3_1",
67 "qwen_3_5",
68 "qwen_3_coder",
69 + "mimo",
70 "qwen_3",
71 "deepseek_v3_2",
72 "glm_4_7",