main
py 265 lines 8.23 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from typing import Any
6
7 import pytest
8
9 from vllm.entrypoints.generate.base.protocol import DeltaMessage
10 from vllm.entrypoints.openai.chat_completion.protocol import (
11 ChatCompletionToolsParam,
12 FunctionDefinition,
13 )
14 from vllm.tool_parsers import ToolParserManager
15 from vllm.tool_parsers.minimax_m3_tool_parser import MinimaxM3ToolParser
16
17 # MinimaxM3ToolParser extends RustToolParser; skip when the PyO3 extension
18 # is absent (mirrors the guard in test_rust_tool_parser.py).
19 pytest.importorskip("vllm._rust_tool_parser")
20
21 pytestmark = [pytest.mark.cpu_test, pytest.mark.skip_global_cleanup]
22
23 NS = "]<]minimax[>["
24 EOS_ID = 99
25
26
27 class FakeTokenizer:
28 """Minimal fake tokenizer for unit tests."""
29
30 def __init__(self):
31 self.model_tokenizer = True
32 self.vocab: dict[str, int] = {}
33
34 def get_vocab(self) -> dict[str, int]:
35 return self.vocab
36
37
38 def sample_tools() -> list[ChatCompletionToolsParam]:
39 return [
40 ChatCompletionToolsParam(
41 function=FunctionDefinition(
42 name="create_order",
43 parameters={
44 "type": "object",
45 "properties": {
46 "user_id": {"type": "integer"},
47 "urgent": {"type": "boolean"},
48 "note": {"type": "string"},
49 "shipping": {
50 "type": "object",
51 "properties": {
52 "city": {"type": "string"},
53 "zip": {"type": "integer"},
54 },
55 },
56 "items": {
57 "type": "array",
58 "items": {
59 "type": "object",
60 "properties": {
61 "sku": {"type": "string"},
62 "qty": {"type": "integer"},
63 },
64 },
65 },
66 "metadata": {
67 "type": "object",
68 "additionalProperties": {"type": "string"},
69 },
70 "duplicate_demo": {"type": "object"},
71 },
72 },
73 ),
74 )
75 ]
76
77
78 @pytest.fixture
79 def parser() -> MinimaxM3ToolParser:
80 return MinimaxM3ToolParser(FakeTokenizer(), tools=sample_tools())
81
82
83 def build_order_call() -> str:
84 return (
85 f"{NS}<tool_call>\n"
86 f'{NS}<invoke name="create_order">'
87 f"{NS}<user_id>42{NS}</user_id>"
88 f"{NS}<urgent>true{NS}</urgent>"
89 f"{NS}<note>Please leave at front desk.{NS}</note>"
90 f"{NS}<shipping>"
91 f"{NS}<city>Singapore{NS}</city>"
92 f"{NS}<zip>018956{NS}</zip>"
93 f"{NS}</shipping>"
94 f"{NS}<items>"
95 f"{NS}<item>{NS}<sku>book-001{NS}</sku>{NS}<qty>2{NS}</qty>{NS}</item>"
96 f"{NS}<item>{NS}<sku>pen-007{NS}</sku>{NS}<qty>5{NS}</qty>{NS}</item>"
97 f"{NS}</items>"
98 f"{NS}<metadata>"
99 f"{NS}<source>mobile{NS}</source>"
100 f"{NS}<campaign>may-launch{NS}</campaign>"
101 f"{NS}</metadata>"
102 f"{NS}<duplicate_demo>"
103 f"{NS}<tag>a{NS}</tag>"
104 f"{NS}<tag>b{NS}</tag>"
105 f"{NS}</duplicate_demo>"
106 f"{NS}</invoke>\n"
107 f"{NS}</tool_call>"
108 )
109
110
111 def build_order_invocation(user_id: int) -> str:
112 return (
113 f'{NS}<invoke name="create_order">'
114 f"{NS}<user_id>{user_id}{NS}</user_id>"
115 f"{NS}</invoke>"
116 )
117
118
119 def build_multiple_order_call() -> str:
120 return (
121 f"{NS}<tool_call>\n"
122 f"{build_order_invocation(1)}\n"
123 f"{build_order_invocation(2)}\n"
124 f"{NS}</tool_call>"
125 )
126
127
128 def _feed(
129 parser: MinimaxM3ToolParser, chunks: list[str | tuple[str, list[int]]]
130 ) -> list[DeltaMessage]:
131 previous = ""
132 results: list[DeltaMessage] = []
133 for chunk in chunks:
134 if isinstance(chunk, tuple):
135 delta, delta_ids = chunk
136 else:
137 delta = chunk
138 delta_ids = []
139
140 current = previous + delta
141 result = parser.extract_tool_calls_streaming(
142 previous_text=previous,
143 current_text=current,
144 delta_text=delta,
145 previous_token_ids=[],
146 current_token_ids=[],
147 delta_token_ids=delta_ids,
148 request=None,
149 )
150 if result is not None:
151 results.append(result)
152 previous = current
153 return results
154
155
156 def _collect_content(results: list[DeltaMessage]) -> str:
157 return "".join(result.content for result in results if result.content)
158
159
160 def _collect_tool_calls(results: list[DeltaMessage]) -> dict[int, dict[str, Any]]:
161 tool_calls: dict[int, dict[str, Any]] = {}
162 for result in results:
163 for tool_call in result.tool_calls or []:
164 tool_calls.setdefault(
165 tool_call.index,
166 {"id": None, "name": "", "arguments": ""},
167 )
168 if tool_call.id:
169 tool_calls[tool_call.index]["id"] = tool_call.id
170 if tool_call.function:
171 if tool_call.function.name:
172 tool_calls[tool_call.index]["name"] += tool_call.function.name
173 if tool_call.function.arguments:
174 tool_calls[tool_call.index]["arguments"] += (
175 tool_call.function.arguments
176 )
177 return tool_calls
178
179
180 def test_minimax_m3_parser_registered():
181 assert ToolParserManager.get_tool_parser("minimax_m3") is MinimaxM3ToolParser
182
183
184 def test_non_streaming_nested_tool_call(parser):
185 result = parser.extract_tool_calls(
186 "I will create it.\n" + build_order_call(),
187 request=None,
188 )
189
190 assert result.tools_called
191 assert result.content == "I will create it.\n"
192 assert len(result.tool_calls) == 1
193 tool_call = result.tool_calls[0]
194 assert tool_call.function.name == "create_order"
195 assert json.loads(tool_call.function.arguments) == {
196 "user_id": 42,
197 "urgent": True,
198 "note": "Please leave at front desk.",
199 "shipping": {"city": "Singapore", "zip": 18956},
200 "items": [
201 {"sku": "book-001", "qty": 2},
202 {"sku": "pen-007", "qty": 5},
203 ],
204 "metadata": {
205 "source": "mobile",
206 "campaign": "may-launch",
207 },
208 "duplicate_demo": {"tag": ["a", "b"]},
209 }
210
211
212 def test_non_streaming_without_tool_call_keeps_content(parser):
213 result = parser.extract_tool_calls("plain response", request=None)
214
215 assert not result.tools_called
216 assert result.tool_calls == []
217 assert result.content == "plain response"
218
219
220 def test_non_streaming_multiple_tool_calls(parser):
221 result = parser.extract_tool_calls(build_multiple_order_call(), request=None)
222
223 assert result.tools_called
224 assert result.content is None
225 assert [tool_call.function.name for tool_call in result.tool_calls] == [
226 "create_order",
227 "create_order",
228 ]
229 assert [
230 json.loads(tool_call.function.arguments)["user_id"]
231 for tool_call in result.tool_calls
232 ] == [1, 2]
233
234
235 def test_streaming_without_tool_call_emits_text(parser):
236 results = _feed(parser, ["plain ", "response"])
237
238 assert _collect_content(results) == "plain response"
239 assert _collect_tool_calls(results) == {}
240
241
242 def test_streaming_nested_tool_call(parser):
243 tool_call_text = build_order_call()
244 results = _feed(
245 parser,
246 [
247 "I will create it.\n",
248 tool_call_text[:5],
249 tool_call_text[5:17],
250 tool_call_text[17:120],
251 tool_call_text[120:],
252 ("", [EOS_ID]),
253 ],
254 )
255
256 assert _collect_content(results) == "I will create it.\n"
257 tool_calls = _collect_tool_calls(results)
258 assert len(tool_calls) == 1
259 assert tool_calls[0]["name"] == "create_order"
260 assert tool_calls[0]["id"] is not None
261 assert json.loads(tool_calls[0]["arguments"]) == json.loads(
262 parser.streamed_args_for_tool[0]
263 )
264 assert json.loads(parser.prev_tool_call_arr[0]["arguments"])["items"][1]["qty"] == 5
265 assert results[-1].content is None