main
py 447 lines 14.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 """Unit tests for DeepSeekV4EngineToolParser."""
5
6 import json
7 from unittest.mock import MagicMock
8
9 import pytest
10 from xgrammar import StructuralTag
11
12 from vllm.entrypoints.openai.chat_completion.protocol import (
13 ChatCompletionNamedFunction,
14 ChatCompletionNamedToolChoiceParam,
15 ChatCompletionRequest,
16 ChatCompletionToolsParam,
17 FunctionDefinition,
18 )
19 from vllm.tool_parsers import ToolParserManager
20 from vllm.tool_parsers.deepseekv4_engine_tool_parser import (
21 DeepSeekV4EngineToolParser,
22 )
23
24 pytestmark = pytest.mark.skip_global_cleanup
25
26 MOCK_TOKENIZER = MagicMock()
27 MOCK_TOKENIZER.get_vocab.return_value = {}
28
29 TC_START = "<|DSML|tool_calls>"
30 TC_END = "</|DSML|tool_calls>"
31 INV_START = '<|DSML|invoke name="'
32 INV_END = "</|DSML|invoke>"
33 PARAM_START = '<|DSML|parameter name="'
34 PARAM_END = "</|DSML|parameter>"
35
36
37 @pytest.fixture
38 def sample_tools() -> list[ChatCompletionToolsParam]:
39 return [
40 ChatCompletionToolsParam(
41 type="function",
42 function={
43 "name": "get_current_weather",
44 "description": "Get the current weather",
45 "parameters": {
46 "type": "object",
47 "properties": {
48 "city": {"type": "string", "description": "The city name"},
49 "state": {"type": "string", "description": "The state code"},
50 "unit": {"type": "string", "enum": ["fahrenheit", "celsius"]},
51 },
52 "required": ["city", "state"],
53 },
54 },
55 ),
56 ChatCompletionToolsParam(
57 type="function",
58 function={
59 "name": "calculate_area",
60 "description": "Calculate area of a shape",
61 "parameters": {
62 "type": "object",
63 "properties": {
64 "shape": {"type": "string"},
65 "dimensions": {"type": "object"},
66 "precision": {"type": "integer"},
67 },
68 },
69 },
70 ),
71 ]
72
73
74 def make_parser(tools=None) -> DeepSeekV4EngineToolParser:
75 return DeepSeekV4EngineToolParser(MOCK_TOKENIZER, tools=tools)
76
77
78 def make_request(tools=None) -> MagicMock:
79 req = MagicMock()
80 req.tools = tools
81 return req
82
83
84 def build_tool_call(func_name: str, params: dict[str, str]) -> str:
85 param_strs = "".join(
86 f'{PARAM_START}{k}" string="true">{v}{PARAM_END}\n' for k, v in params.items()
87 )
88 return f'{TC_START}\n{INV_START}{func_name}">\n{param_strs}{INV_END}\n{TC_END}'
89
90
91 def stream(parser: DeepSeekV4EngineToolParser, full_text: str, chunk_size: int = 7):
92 deltas = []
93 previous_text = ""
94 for start in range(0, len(full_text), chunk_size):
95 delta_text = full_text[start : start + chunk_size]
96 current_text = previous_text + delta_text
97 delta = parser.extract_tool_calls_streaming(
98 previous_text=previous_text,
99 current_text=current_text,
100 delta_text=delta_text,
101 previous_token_ids=[],
102 current_token_ids=[],
103 delta_token_ids=[1],
104 request=make_request(),
105 )
106 previous_text = current_text
107 if delta is not None:
108 deltas.append(delta)
109 return deltas
110
111
112 def reconstruct_args(deltas, tool_index: int = 0) -> str:
113 fragments = []
114 for delta in deltas:
115 if delta.tool_calls:
116 for tool_call in delta.tool_calls:
117 if (
118 tool_call.index == tool_index
119 and tool_call.function
120 and tool_call.function.arguments
121 ):
122 fragments.append(tool_call.function.arguments)
123 return "".join(fragments)
124
125
126 def test_registered():
127 assert (
128 ToolParserManager.get_tool_parser("deepseek_v4") is DeepSeekV4EngineToolParser
129 )
130
131
132 def test_extract_tool_calls():
133 parser = make_parser()
134 model_output = "Let me check. " + build_tool_call(
135 "get_weather", {"location": "Beijing", "unit": "celsius"}
136 )
137
138 result = parser.extract_tool_calls(model_output, make_request())
139
140 assert result.tools_called
141 assert result.content == "Let me check. "
142 assert len(result.tool_calls) == 1
143 tool_call = result.tool_calls[0]
144 assert tool_call.function.name == "get_weather"
145 assert json.loads(tool_call.function.arguments) == {
146 "location": "Beijing",
147 "unit": "celsius",
148 }
149
150
151 def test_function_calls_wrapper_is_not_recognized():
152 # The V3.2 wrapper is not a V4 terminal, so it passes through as content,
153 # but the invoke inside it is still parsed (tool calls anchor on the invoke).
154 parser = make_parser()
155 model_output = build_tool_call("search", {"query": "vllm"}).replace(
156 "tool_calls", "function_calls"
157 )
158
159 result = parser.extract_tool_calls(model_output, make_request())
160
161 assert result.tools_called
162 assert result.tool_calls[0].function.name == "search"
163 assert result.content == "<|DSML|function_calls>\n"
164
165
166 def test_missing_tool_calls_wrapper_is_recovered():
167 # Regression for #48931: at long context the model omits the
168 # <|DSML|tool_calls> START token but still emits a complete invoke.
169 parser = make_parser()
170 model_output = build_tool_call("search", {"query": "vllm"}).replace(
171 TC_START + "\n", ""
172 )
173 assert TC_START not in model_output
174
175 result = parser.extract_tool_calls(model_output, make_request())
176
177 assert result.tools_called
178 assert result.tool_calls[0].function.name == "search"
179 assert json.loads(result.tool_calls[0].function.arguments) == {"query": "vllm"}
180 assert result.content is None
181
182 deltas = stream(make_parser(), model_output, chunk_size=3)
183 names = [
184 tc.function.name
185 for d in deltas
186 if d.tool_calls
187 for tc in d.tool_calls
188 if tc.function.name
189 ]
190 assert names == ["search"]
191 assert json.loads(reconstruct_args(deltas)) == {"query": "vllm"}
192 assert not any(d.content and "DSML" in d.content for d in deltas)
193
194
195 def test_streaming_extracts_complete_invokes():
196 parser = make_parser()
197 full_text = build_tool_call("search", {"query": "deepseek v4"})
198
199 deltas = stream(parser, full_text, chunk_size=5)
200
201 names = [
202 tool_call.function.name
203 for delta in deltas
204 if delta.tool_calls
205 for tool_call in delta.tool_calls
206 if tool_call.function.name
207 ]
208 assert names == ["search"]
209 assert json.loads(reconstruct_args(deltas)) == {"query": "deepseek v4"}
210
211
212 def test_streaming_emits_incremental_argument_chunks():
213 tool = ChatCompletionToolsParam(
214 function=FunctionDefinition(
215 name="plan_trip",
216 parameters={
217 "type": "object",
218 "properties": {
219 "days": {"type": "integer"},
220 "flexible": {"type": "boolean"},
221 "cities": {"type": "array", "items": {"type": "string"}},
222 "notes": {"type": "string"},
223 },
224 },
225 ),
226 )
227 parser = make_parser(tools=[tool])
228 full_text = (
229 f"{TC_START}\n"
230 f'{INV_START}plan_trip">\n'
231 f'{PARAM_START}days" string="false">3{PARAM_END}\n'
232 f'{PARAM_START}flexible" string="false">false{PARAM_END}\n'
233 f'{PARAM_START}cities" string="false">'
234 f'["Beijing","Shanghai","Tokyo","New York"]{PARAM_END}\n'
235 f'{PARAM_START}notes" string="true">靠窗座位{PARAM_END}\n'
236 f"{INV_END}\n"
237 f"{TC_END}"
238 )
239
240 deltas = stream(parser, full_text, chunk_size=4)
241 arg_chunks = [
242 tool_call.function.arguments
243 for delta in deltas
244 for tool_call in delta.tool_calls or []
245 if tool_call.function and tool_call.function.arguments is not None
246 ]
247
248 assert len([chunk for chunk in arg_chunks if chunk]) > 2
249 assert json.loads("".join(arg_chunks)) == {
250 "days": 3,
251 "flexible": False,
252 "cities": ["Beijing", "Shanghai", "Tokyo", "New York"],
253 "notes": "靠窗座位",
254 }
255
256
257 def _with_strict(
258 tools: list[ChatCompletionToolsParam],
259 ) -> list[ChatCompletionToolsParam]:
260 return [
261 ChatCompletionToolsParam(
262 type=t.type,
263 function=FunctionDefinition(
264 name=t.function.name,
265 description=t.function.description,
266 parameters=t.function.parameters,
267 strict=True,
268 ),
269 )
270 for t in tools
271 ]
272
273
274 def test_get_vllm_registry_structural_tag_returns_structural_tag(
275 sample_tools: list[ChatCompletionToolsParam],
276 ) -> None:
277 parser = make_parser()
278 strict_tools = _with_strict(sample_tools)
279 req = ChatCompletionRequest(
280 messages=[],
281 model="m",
282 tools=strict_tools,
283 tool_choice="auto",
284 )
285 tag = parser.get_structural_tag(req)
286 assert isinstance(tag, StructuralTag)
287
288 req = ChatCompletionRequest(
289 messages=[],
290 model="m",
291 tools=sample_tools,
292 tool_choice="required",
293 )
294 tag = parser.get_structural_tag(req)
295 assert isinstance(tag, StructuralTag)
296
297 if sample_tools:
298 tool = sample_tools[0]
299 req = ChatCompletionRequest(
300 messages=[],
301 model="m",
302 tools=sample_tools,
303 )
304 req.tool_choice = ChatCompletionNamedToolChoiceParam(
305 function=ChatCompletionNamedFunction(name=tool.function.name)
306 )
307 tag = parser.get_structural_tag(req)
308 assert isinstance(tag, StructuralTag)
309
310
311 def test_extract_tool_calls_arguments_wrapper():
312 mock_tokenizer = MagicMock()
313 mock_tokenizer.get_vocab.return_value = {}
314
315 tool = ChatCompletionToolsParam(
316 type="function",
317 function={
318 "name": "get_weather",
319 "parameters": {
320 "type": "object",
321 "properties": {"location": {"type": "string"}},
322 },
323 },
324 )
325
326 parser = DeepSeekV4EngineToolParser(mock_tokenizer, tools=[tool])
327 request = MagicMock()
328 request.tools = [tool]
329
330 model_output = (
331 f"{TC_START}"
332 f'{INV_START}get_weather">'
333 f'{PARAM_START}arguments" string="false">{{"location":"Beijing"}}{PARAM_END}'
334 f"{INV_END}"
335 f"{TC_END}"
336 )
337
338 result = parser.extract_tool_calls(model_output, request)
339 assert result.tools_called
340 args = json.loads(result.tool_calls[0].function.arguments)
341 assert args == {"location": "Beijing"}
342
343
344 _ANGLE_BRACKET_TOOL = ChatCompletionToolsParam(
345 function=FunctionDefinition(
346 name="run_command",
347 parameters={
348 "type": "object",
349 "properties": {
350 "command": {"type": "string"},
351 },
352 },
353 ),
354 )
355
356
357 @pytest.mark.parametrize(
358 "tools",
359 [[_ANGLE_BRACKET_TOOL], None],
360 ids=["with_tools", "without_tools"],
361 )
362 def test_no_dsml_closing_tag_leak_in_streamed_args(tools):
363 """Streaming must not leak </|DSML|parameter> into argument values.
364
365 When a parameter value contains '>' (e.g. shell redirects like
366 '2>&1'), certain chunk boundaries cause the incremental lexer to
367 emit the closing delimiter text as part of the content token. The
368 partial regex then captures it as part of the value, violating the
369 prefix invariant and corrupting the streamed JSON.
370 """
371 full_text = build_tool_call("run_command", {"command": "git --version 2>&1"})
372 expected = {"command": "git --version 2>&1"}
373
374 for chunk_size in range(1, len(full_text) + 1):
375 parser = make_parser(tools=tools)
376 deltas = stream(parser, full_text, chunk_size=chunk_size)
377 args_str = reconstruct_args(deltas)
378 assert args_str, f"No args emitted at chunk_size={chunk_size}"
379 assert "DSML" not in args_str, (
380 f"DSML marker leaked into args at chunk_size={chunk_size}: {args_str!r}"
381 )
382 parsed = json.loads(args_str)
383 assert parsed == expected, (
384 f"Args mismatch at chunk_size={chunk_size}: "
385 f"got {parsed!r}, expected {expected!r}"
386 )
387
388
389 def test_non_streaming_extract_with_angle_brackets():
390 """Non-streaming extraction must correctly handle '>' in values."""
391 parser = make_parser()
392 full_text = build_tool_call("run_command", {"command": "git --version 2>&1"})
393 result = parser.extract_tool_calls(full_text, make_request())
394
395 assert result.tools_called
396 assert len(result.tool_calls) == 1
397 args = json.loads(result.tool_calls[0].function.arguments)
398 assert args == {"command": "git --version 2>&1"}
399 assert "DSML" not in result.tool_calls[0].function.arguments
400
401
402 def test_composed_schema_converts_object_and_array_params():
403 tool = ChatCompletionToolsParam(
404 type="function",
405 function={
406 "name": "set_timer",
407 "parameters": {
408 "type": "object",
409 "properties": {
410 "wait": {
411 "anyOf": [
412 {"type": "object"},
413 {"type": "null"},
414 ],
415 },
416 "patches": {
417 "allOf": [
418 {"type": "array", "items": {"type": "object"}},
419 ],
420 },
421 },
422 },
423 },
424 )
425 parser = make_parser(tools=[tool])
426 request = make_request(tools=[tool])
427 model_output = (
428 f"{TC_START}\n"
429 f'{INV_START}set_timer">\n'
430 f'{PARAM_START}wait" string="false">'
431 f'{{"type":"for","minutes":2880}}'
432 f"{PARAM_END}\n"
433 f'{PARAM_START}patches" string="false">'
434 f'[{{"op":"replace","path":"/schedule","value":"quiet"}}]'
435 f"{PARAM_END}\n"
436 f"{INV_END}\n"
437 f"{TC_END}"
438 )
439
440 result = parser.extract_tool_calls(model_output, request)
441
442 assert result.tools_called
443 args = json.loads(result.tool_calls[0].function.arguments)
444 assert args == {
445 "wait": {"type": "for", "minutes": 2880},
446 "patches": [{"op": "replace", "path": "/schedule", "value": "quiet"}],
447 }