| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """MiniMax M2 parser for XML-style tool calls. |
| 4 | |
| 5 | MiniMax M2 tool call format:: |
| 6 | |
| 7 | <minimax:tool_call><invoke name="get_weather"> |
| 8 | <parameter name="city">Seattle</parameter> |
| 9 | </invoke></minimax:tool_call> |
| 10 | |
| 11 | Each ``<invoke>`` block becomes one tool call. The argument body consists |
| 12 | of ``<parameter name="...">...</parameter>`` tags. |
| 13 | """ |
| 14 | |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import functools |
| 18 | import json |
| 19 | |
| 20 | import regex as re |
| 21 | |
| 22 | from vllm.parser.engine.events import EventType |
| 23 | from vllm.parser.engine.parser_engine import ParserEngine |
| 24 | from vllm.parser.engine.parser_engine_config import ( |
| 25 | ParserEngineConfig, |
| 26 | ParserState, |
| 27 | Transition, |
| 28 | ) |
| 29 | |
| 30 | TOOL_CALL_START = "<minimax:tool_call>" |
| 31 | TOOL_CALL_END = "</minimax:tool_call>" |
| 32 | THINK_START = "<think>" |
| 33 | THINK_END = "</think>" |
| 34 | INVOKE_PREFIX_DQ = '<invoke name="' |
| 35 | INVOKE_PREFIX_SQ = "<invoke name='" |
| 36 | INVOKE_PREFIX_UNQUOTED = "<invoke name=" |
| 37 | INVOKE_END = "</invoke>" |
| 38 | NAME_END_DQ = '">' |
| 39 | NAME_END_SQ = "'>" |
| 40 | NAME_END_UNQUOTED = ">" |
| 41 | PARAM_START = "<parameter name=" |
| 42 | PARAM_END = "</parameter>" |
| 43 | _PARAM_RE = re.compile( |
| 44 | r"<\s*parameter\s+name\s*=\s*" |
| 45 | r"(?:\"(?P<dq_name>[^\"]*)\"|'(?P<sq_name>[^']*)'|(?P<bare_name>[^>\s]+))" |
| 46 | r"\s*>" |
| 47 | r"(?P<value>.*?)" |
| 48 | r"(?:<\s*/\s*parameter\s*>|(?=<\s*parameter\s+name\s*=))", |
| 49 | re.DOTALL, |
| 50 | ) |
| 51 | _PARTIAL_PARAM_RE = re.compile( |
| 52 | r"<\s*parameter\s+name\s*=\s*" |
| 53 | r"(?:\"(?P<dq_name>[^\"]*)\"|'(?P<sq_name>[^']*)'|(?P<bare_name>[^>\s]+))" |
| 54 | r"\s*>" |
| 55 | r"(?P<value>.*)$", |
| 56 | re.DOTALL, |
| 57 | ) |
| 58 | |
| 59 | |
| 60 | def _minimax_m2_arg_converter(raw_args: str, partial: bool) -> str: |
| 61 | params: dict[str, object] = {} |
| 62 | |
| 63 | for match in _PARAM_RE.finditer(raw_args): |
| 64 | name = ( |
| 65 | match.group("dq_name") |
| 66 | or match.group("sq_name") |
| 67 | or match.group("bare_name") |
| 68 | or "" |
| 69 | ).strip() |
| 70 | if not name: |
| 71 | continue |
| 72 | # Keep the value verbatim (like the glm47 converter): it is inline |
| 73 | # between `>` and `</parameter>`, so surrounding whitespace is data. |
| 74 | params[name] = match.group("value") |
| 75 | |
| 76 | if partial: |
| 77 | remaining = _PARAM_RE.sub("", raw_args) |
| 78 | match = _PARTIAL_PARAM_RE.search(remaining) |
| 79 | if match: |
| 80 | name = ( |
| 81 | match.group("dq_name") |
| 82 | or match.group("sq_name") |
| 83 | or match.group("bare_name") |
| 84 | or "" |
| 85 | ).strip() |
| 86 | if name: |
| 87 | # Verbatim, same as the complete-match loop above. |
| 88 | params[name] = match.group("value") |
| 89 | |
| 90 | return json.dumps(params, ensure_ascii=False) |
| 91 | |
| 92 | |
| 93 | @functools.cache |
| 94 | def minimax_m2_config() -> ParserEngineConfig: |
| 95 | return ParserEngineConfig( |
| 96 | name="minimax_m2", |
| 97 | initial_state=ParserState.REASONING, |
| 98 | wait_for_reasoning=True, |
| 99 | terminals={ |
| 100 | "THINK_START": THINK_START, |
| 101 | "THINK_END": THINK_END, |
| 102 | "TOOL_START": TOOL_CALL_START, |
| 103 | "PARAM_START": PARAM_START, |
| 104 | "PARAM_END": PARAM_END, |
| 105 | "TOOL_END": TOOL_CALL_END, |
| 106 | "INVOKE_PREFIX_DQ": INVOKE_PREFIX_DQ, |
| 107 | "INVOKE_PREFIX_SQ": INVOKE_PREFIX_SQ, |
| 108 | "INVOKE_PREFIX_UNQUOTED": INVOKE_PREFIX_UNQUOTED, |
| 109 | "INVOKE_END": INVOKE_END, |
| 110 | "NAME_END_DQ": NAME_END_DQ, |
| 111 | "NAME_END_SQ": NAME_END_SQ, |
| 112 | "NAME_END_UNQUOTED": NAME_END_UNQUOTED, |
| 113 | }, |
| 114 | token_id_terminals={ |
| 115 | "THINK_START": THINK_START, |
| 116 | "THINK_END": THINK_END, |
| 117 | "TOOL_START": TOOL_CALL_START, |
| 118 | "TOOL_END": TOOL_CALL_END, |
| 119 | }, |
| 120 | transitions={ |
| 121 | (ParserState.REASONING, "THINK_START"): Transition( |
| 122 | ParserState.REASONING, |
| 123 | (), |
| 124 | ), |
| 125 | (ParserState.REASONING, "THINK_END"): Transition( |
| 126 | ParserState.CONTENT, |
| 127 | (EventType.REASONING_END,), |
| 128 | ), |
| 129 | (ParserState.CONTENT, "THINK_END"): Transition( |
| 130 | ParserState.CONTENT, |
| 131 | (), |
| 132 | ), |
| 133 | (ParserState.REASONING, "TOOL_START"): Transition( |
| 134 | ParserState.TOOL_PREAMBLE, |
| 135 | (EventType.REASONING_END,), |
| 136 | ), |
| 137 | (ParserState.CONTENT, "TOOL_START"): Transition( |
| 138 | ParserState.TOOL_PREAMBLE, |
| 139 | (), |
| 140 | ), |
| 141 | (ParserState.TOOL_ARGS, "PARAM_START"): Transition( |
| 142 | ParserState.TOOL_ARGS, |
| 143 | (EventType.ARG_VALUE_CHUNK,), |
| 144 | ), |
| 145 | (ParserState.TOOL_ARGS, "PARAM_END"): Transition( |
| 146 | ParserState.TOOL_ARGS, |
| 147 | (EventType.ARG_VALUE_CHUNK,), |
| 148 | ), |
| 149 | (ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition( |
| 150 | ParserState.CONTENT, |
| 151 | (), |
| 152 | ), |
| 153 | (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition( |
| 154 | ParserState.CONTENT, |
| 155 | (), |
| 156 | ), |
| 157 | (ParserState.CONTENT, "TOOL_END"): Transition( |
| 158 | ParserState.CONTENT, |
| 159 | (), |
| 160 | ), |
| 161 | (ParserState.TOOL_ARGS, "INVOKE_END"): Transition( |
| 162 | ParserState.TOOL_BETWEEN, |
| 163 | (EventType.TOOL_CALL_END,), |
| 164 | ), |
| 165 | **{ |
| 166 | (state, terminal): Transition( |
| 167 | ParserState.TOOL_NAME, |
| 168 | (EventType.TOOL_CALL_START,), |
| 169 | ) |
| 170 | for state in ( |
| 171 | ParserState.CONTENT, |
| 172 | ParserState.TOOL_PREAMBLE, |
| 173 | ParserState.TOOL_BETWEEN, |
| 174 | ) |
| 175 | for terminal in ( |
| 176 | "INVOKE_PREFIX_DQ", |
| 177 | "INVOKE_PREFIX_SQ", |
| 178 | "INVOKE_PREFIX_UNQUOTED", |
| 179 | ) |
| 180 | }, |
| 181 | **{ |
| 182 | (ParserState.TOOL_NAME, terminal): Transition( |
| 183 | ParserState.TOOL_ARGS, |
| 184 | (), |
| 185 | ) |
| 186 | for terminal in ( |
| 187 | "NAME_END_DQ", |
| 188 | "NAME_END_SQ", |
| 189 | "NAME_END_UNQUOTED", |
| 190 | ) |
| 191 | }, |
| 192 | }, |
| 193 | arg_converter=_minimax_m2_arg_converter, |
| 194 | stream_arg_deltas=True, |
| 195 | tool_args_json=False, |
| 196 | validate_tool_names=True, |
| 197 | ) |
| 198 | |
| 199 | |
| 200 | class MinimaxM2Parser(ParserEngine): |
| 201 | """MiniMax M2 parser backed by the declarative parser engine.""" |
| 202 | |
| 203 | def __init__(self, tokenizer, tools=None, **kwargs) -> None: |
| 204 | kwargs.setdefault("parser_engine_config", minimax_m2_config()) |
| 205 | super().__init__(tokenizer, tools, **kwargs) |
| 206 | self._think_end_token_id = self.vocab.get(THINK_END) |
| 207 | |
| 208 | def extract_content_ids(self, input_ids: list[int]) -> list[int]: |
| 209 | end_id = self._think_end_token_id |
| 210 | if end_id is None: |
| 211 | return [] |
| 212 | for i in range(len(input_ids) - 1, -1, -1): |
| 213 | if input_ids[i] == end_id: |
| 214 | return input_ids[i + 1 :] |
| 215 | return [] |