[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>
This commit is contained in:
co-authored by
Codex
yuchuan
Bugen Zhao
Claude Opus 5.5
parent
79a0307d74
commit
dfc8e0f3e2
@@ -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 `<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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"<tool_call><function=run><parameter=text>{value}</parameter>"
|
||||
"</function></tool_call>"
|
||||
)
|
||||
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}
|
||||
|
||||
@@ -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"<function={tc.name}>"]
|
||||
for key, value in tc.arguments.items():
|
||||
parts.append(f"<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
|
||||
parts.append("</function>")
|
||||
return [
|
||||
("<tool_call>", True),
|
||||
("".join(parts), False),
|
||||
("</tool_call>", 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,
|
||||
}
|
||||
|
||||
@@ -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 = "<tool_call><function=run><parameter=n>2</parameter></function></tool_call>"
|
||||
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("<parameter=n>2</parameter>", ""),
|
||||
call.replace(">2</parameter>", ">bad</parameter>"),
|
||||
call.replace(">2</parameter>", ">0</parameter>"),
|
||||
call.replace("<parameter=n>", "<parameter=other>"),
|
||||
call.replace("</function>", "<parameter=n>3</parameter></function>"),
|
||||
"<think>plan</think>" + 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("<parameter=n>", "<parameter=other>")
|
||||
)
|
||||
|
||||
|
||||
@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 = (
|
||||
"<tool_call><function=get_weather><parameter=city>北京</parameter>"
|
||||
"</function></tool_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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user