main
py 215 lines 6.87 KB
Raw
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 []