[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北京 &\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",