main
py 328 lines 10.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from collections.abc import Sequence
6 from unittest.mock import MagicMock
7
8 import pytest
9
10 from vllm.entrypoints.openai.chat_completion.protocol import (
11 ChatCompletionRequest,
12 ChatCompletionToolsParam,
13 )
14 from vllm.tool_parsers.rust_tool_parser import RustToolParser
15
16 # The PyO3 extension is an optional build artifact; skip when absent.
17 _rust_tool_parser = pytest.importorskip("vllm._rust_tool_parser")
18
19 MOCK_TOKENIZER = MagicMock()
20 MOCK_TOKENIZER.get_vocab.return_value = {}
21
22 TC_START = "<|DSML|tool_calls>"
23 TC_END = "</|DSML|tool_calls>"
24 INV_START = '<|DSML|invoke name="'
25 INV_END = "</|DSML|invoke>"
26 PARAM_START = '<|DSML|parameter name="'
27 PARAM_END = "</|DSML|parameter>"
28
29
30 class DeepSeekV4RustToolParser(RustToolParser):
31 rust_parser_name = "DeepSeekV4ToolParser"
32 tool_call_start_token = TC_START
33
34
35 class KimiK2RustToolParser(RustToolParser):
36 rust_parser_name = "KimiK2ToolParser"
37 tool_call_start_token = "<|tool_calls_section_begin|>"
38
39
40 def sample_tools() -> list[ChatCompletionToolsParam]:
41 return [
42 ChatCompletionToolsParam(
43 type="function",
44 function={
45 "name": "get_weather",
46 "description": "Get weather for a location",
47 "parameters": {
48 "type": "object",
49 "properties": {
50 "location": {"type": "string"},
51 "date": {"type": "string"},
52 },
53 },
54 },
55 ),
56 ChatCompletionToolsParam(
57 type="function",
58 function={
59 "name": "add",
60 "description": "Add two integers",
61 "parameters": {
62 "type": "object",
63 "properties": {
64 "x": {"type": "integer"},
65 "y": {"type": "integer"},
66 },
67 },
68 },
69 ),
70 ]
71
72
73 EXPECTED_CALLS = [
74 ("get_weather", {"location": "SF", "date": "2024-01-16"}),
75 ("add", {"x": 3, "y": 5}),
76 ]
77
78
79 def build_invoke(
80 function_name: str,
81 params: Sequence[tuple[str, str, bool]],
82 ) -> str:
83 param_text = "\n".join(
84 f'{PARAM_START}{name}" string="{str(is_string).lower()}">{value}{PARAM_END}'
85 for name, value, is_string in params
86 )
87 return f'{INV_START}{function_name}">\n{param_text}\n{INV_END}\n'
88
89
90 def build_tool_call() -> str:
91 weather = build_invoke(
92 "get_weather",
93 [
94 ("location", "SF", True),
95 ("date", "2024-01-16", True),
96 ],
97 )
98 add = build_invoke(
99 "add",
100 [
101 ("x", "3", False),
102 ("y", "5", False),
103 ],
104 )
105 return f"{TC_START}\n{weather}{add}{TC_END}"
106
107
108 def parse_streaming(
109 parser: DeepSeekV4RustToolParser,
110 text: str,
111 chunk_size: int,
112 ) -> list:
113 deltas = []
114 previous_text = ""
115 for start in range(0, len(text), chunk_size):
116 delta_text = text[start : start + chunk_size]
117 current_text = previous_text + delta_text
118 delta = parser.extract_tool_calls_streaming(
119 previous_text=previous_text,
120 current_text=current_text,
121 delta_text=delta_text,
122 previous_token_ids=[],
123 current_token_ids=[],
124 delta_token_ids=[1],
125 request=MagicMock(),
126 )
127 previous_text = current_text
128 if delta is not None:
129 deltas.append(delta)
130
131 delta = parser.extract_tool_calls_streaming(
132 previous_text=previous_text,
133 current_text=previous_text,
134 delta_text="",
135 previous_token_ids=[],
136 current_token_ids=[],
137 delta_token_ids=[2],
138 request=MagicMock(),
139 )
140 if delta is not None:
141 deltas.append(delta)
142
143 return deltas
144
145
146 def collect_streamed_arguments(deltas: Sequence, tool_index: int = 0) -> str:
147 return "".join(
148 tool_call.function.arguments
149 for delta in deltas
150 for tool_call in delta.tool_calls or []
151 if (
152 tool_call.index == tool_index
153 and tool_call.function is not None
154 and tool_call.function.arguments is not None
155 )
156 )
157
158
159 def test_rust_tool_parser_extension_typed_api() -> None:
160 tools = [
161 _rust_tool_parser.Tool(
162 tool.function.name,
163 tool.function.description,
164 tool.function.parameters,
165 None,
166 )
167 for tool in sample_tools()
168 ]
169 parser = _rust_tool_parser.ToolParser("DeepSeekV4ToolParser", tools)
170 output = _rust_tool_parser.ToolParserOutput()
171
172 parser.parse_into(build_tool_call(), output)
173 output.append(parser.finish())
174 output = output.coalesce()
175
176 assert parser.preserve_special_tokens()
177 assert output.normal_text == ""
178 assert len(output.calls) == 2
179 for call, (name, arguments) in zip(output.calls, EXPECTED_CALLS):
180 assert call.name == name
181 assert json.loads(call.arguments) == arguments
182
183
184 def test_rust_tool_parser_adapter_extracts_complete_output() -> None:
185 tools = sample_tools()
186 parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=tools)
187
188 result = parser.extract_tool_calls(
189 "Let me create it. " + build_tool_call(),
190 ChatCompletionRequest(messages=[], model="m", tools=tools),
191 )
192
193 assert result.tools_called
194 assert result.content == "Let me create it. "
195 assert len(result.tool_calls) == 2
196 for tool_call, (name, arguments) in zip(result.tool_calls, EXPECTED_CALLS):
197 assert tool_call.function.name == name
198 assert json.loads(tool_call.function.arguments) == arguments
199
200
201 def test_rust_tool_parser_adapter_streaming_handles_multiple_calls() -> None:
202 parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools())
203
204 deltas = parse_streaming(parser, build_tool_call(), chunk_size=5)
205
206 names = [
207 tool_call.function.name
208 for delta in deltas
209 for tool_call in delta.tool_calls or []
210 if tool_call.function is not None and tool_call.function.name is not None
211 ]
212 assert names == [name for name, _ in EXPECTED_CALLS]
213 for index, (_, arguments) in enumerate(EXPECTED_CALLS):
214 assert json.loads(collect_streamed_arguments(deltas, index)) == arguments
215
216
217 def test_rust_tool_parser_adapter_ignores_midstream_empty_delta() -> None:
218 parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools())
219 text = build_tool_call()
220 split_at = len(TC_START) + 8
221 deltas = []
222 previous_text = ""
223
224 for delta_text in (text[:split_at], "", text[split_at:], ""):
225 current_text = previous_text + delta_text
226 delta = parser.extract_tool_calls_streaming(
227 previous_text=previous_text,
228 current_text=current_text,
229 delta_text=delta_text,
230 previous_token_ids=[],
231 current_token_ids=[],
232 delta_token_ids=[1],
233 request=MagicMock(),
234 )
235 previous_text = current_text
236 if delta is not None:
237 deltas.append(delta)
238
239 names = [
240 tool_call.function.name
241 for delta in deltas
242 for tool_call in delta.tool_calls or []
243 if tool_call.function is not None and tool_call.function.name is not None
244 ]
245 assert names == [name for name, _ in EXPECTED_CALLS]
246 for index, (_, arguments) in enumerate(EXPECTED_CALLS):
247 assert json.loads(collect_streamed_arguments(deltas, index)) == arguments
248
249
250 KIMI_EXPECTED_IDS = ["functions.get_weather:0", "functions.add:1"]
251
252
253 def build_kimi_tool_call() -> str:
254 return (
255 "<|tool_calls_section_begin|>"
256 "<|tool_call_begin|>functions.get_weather:0<|tool_call_argument_begin|>"
257 '{"location": "SF", "date": "2024-01-16"}<|tool_call_end|>'
258 "<|tool_call_begin|>functions.add:1<|tool_call_argument_begin|>"
259 '{"x": 3, "y": 5}<|tool_call_end|>'
260 "<|tool_calls_section_end|>"
261 )
262
263
264 def test_rust_tool_parser_adapter_complete_prefers_model_tool_call_ids() -> None:
265 tools = sample_tools()
266 parser = KimiK2RustToolParser(MOCK_TOKENIZER, tools=tools)
267
268 result = parser.extract_tool_calls(
269 "Let me check. " + build_kimi_tool_call(),
270 ChatCompletionRequest(messages=[], model="m", tools=tools),
271 )
272
273 assert result.tools_called
274 assert [tool_call.id for tool_call in result.tool_calls] == KIMI_EXPECTED_IDS
275 for tool_call, (name, arguments) in zip(result.tool_calls, EXPECTED_CALLS):
276 assert tool_call.function.name == name
277 assert json.loads(tool_call.function.arguments) == arguments
278
279
280 def test_rust_tool_parser_adapter_streaming_prefers_model_tool_call_ids() -> None:
281 parser = KimiK2RustToolParser(MOCK_TOKENIZER, tools=sample_tools())
282
283 deltas = parse_streaming(parser, build_kimi_tool_call(), chunk_size=5)
284
285 ids = [
286 tool_call.id
287 for delta in deltas
288 for tool_call in delta.tool_calls or []
289 if tool_call.id is not None
290 ]
291 assert ids == KIMI_EXPECTED_IDS
292 for index, (_, arguments) in enumerate(EXPECTED_CALLS):
293 assert json.loads(collect_streamed_arguments(deltas, index)) == arguments
294
295
296 def test_rust_tool_parser_adapter_streaming_generates_ids_as_fallback() -> None:
297 # DeepSeekV4 never emits model tool call IDs, so the bridge mints them.
298 parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools())
299
300 deltas = parse_streaming(parser, build_tool_call(), chunk_size=5)
301
302 ids = [
303 tool_call.id
304 for delta in deltas
305 for tool_call in delta.tool_calls or []
306 if tool_call.function is not None and tool_call.function.name is not None
307 ]
308 assert len(ids) == len(EXPECTED_CALLS)
309 assert all(ids)
310 assert len(set(ids)) == len(ids)
311
312
313 def test_rust_tool_parser_adapter_adjust_request_is_opaque() -> None:
314 tools = sample_tools()
315 parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=tools)
316 request = ChatCompletionRequest(
317 messages=[],
318 model="m",
319 tools=tools,
320 tool_choice="required",
321 skip_special_tokens=True,
322 )
323
324 adjusted = parser.adjust_request(request)
325
326 assert adjusted is request
327 assert adjusted.skip_special_tokens is False
328 assert adjusted.structured_outputs is None