main
py 311 lines 11.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for DeepSeek V3.2 parser engine semantics.
4
5 V3.2 uses the same DSML parameter format as V4 but wraps tool calls in
6 ``<|DSML|function_calls>`` instead of ``<|DSML|tool_calls>`` and has
7 no reasoning (``<think>``/``</think>``) support.
8 """
9
10 import json
11
12 import pytest
13
14 from tests.parser.engine.conftest import make_mock_tokenizer
15 from tests.parser.engine.streaming_helpers import (
16 collect_content,
17 collect_function_name,
18 collect_tool_arguments,
19 simulate_tool_streaming,
20 )
21 from vllm.parser.deepseek_v4 import (
22 DSML_INVOKE_END,
23 DSML_INVOKE_NAME_END,
24 DSML_INVOKE_PREFIX,
25 )
26 from vllm.parser.deepseek_v32 import (
27 DSML_FUNC_END,
28 DSML_FUNC_START,
29 DeepSeekV32Parser,
30 )
31 from vllm.parser.engine.parser_engine_config import ParserState
32
33 _PARAM_OPEN = '|DSML|parameter name="{name}" string="{is_str}">'
34 _PARAM_CLOSE = "</|DSML|parameter>"
35
36
37 def _param(name: str, is_str: str, value: str) -> str:
38 return f"<{_PARAM_OPEN.format(name=name, is_str=is_str)}{value}{_PARAM_CLOSE}"
39
40
41 def _invoke(name: str, *params: str) -> str:
42 body = "\n".join(params)
43 return (
44 f"{DSML_INVOKE_PREFIX}{name}{DSML_INVOKE_NAME_END}\n{body}\n{DSML_INVOKE_END}"
45 )
46
47
48 def _func_calls(*invocations: str) -> str:
49 body = "\n".join(invocations)
50 return f"{DSML_FUNC_START}\n{body}\n{DSML_FUNC_END}"
51
52
53 def _make_tool(name, properties):
54 from vllm.entrypoints.openai.chat_completion.protocol import (
55 ChatCompletionToolsParam,
56 )
57
58 return ChatCompletionToolsParam(
59 type="function",
60 function={
61 "name": name,
62 "parameters": {
63 "type": "object",
64 "properties": properties,
65 },
66 },
67 )
68
69
70 @pytest.fixture
71 def mock_tokenizer():
72 return make_mock_tokenizer({})
73
74
75 @pytest.fixture
76 def mock_request():
77 from unittest.mock import MagicMock
78
79 from vllm.entrypoints.openai.chat_completion.protocol import (
80 ChatCompletionRequest,
81 )
82
83 req = MagicMock(spec=ChatCompletionRequest)
84 req.tools = []
85 req.tool_choice = "auto"
86 return req
87
88
89 # ── Non-streaming extraction ────────────────────────────────────────
90
91
92 class TestNonStreaming:
93 def test_no_tool_call(self, mock_tokenizer, mock_request):
94 parser = DeepSeekV32Parser(mock_tokenizer)
95 result = parser.extract_tool_calls("Hello world", mock_request)
96 assert not result.tools_called
97 assert result.content == "Hello world"
98
99 def test_single_tool(self, mock_tokenizer, mock_request):
100 text = _func_calls(
101 _invoke("get_weather", _param("city", "true", "SF")),
102 )
103 parser = DeepSeekV32Parser(mock_tokenizer)
104 result = parser.extract_tool_calls(text, mock_request)
105 assert result.tools_called
106 assert len(result.tool_calls) == 1
107 assert result.tool_calls[0].function.name == "get_weather"
108 args = json.loads(result.tool_calls[0].function.arguments)
109 assert args == {"city": "SF"}
110
111 def test_parallel_tools(self, mock_tokenizer, mock_request):
112 text = _func_calls(
113 _invoke("get_weather", _param("city", "true", "SF")),
114 _invoke("get_weather", _param("city", "true", "NYC")),
115 )
116 parser = DeepSeekV32Parser(mock_tokenizer)
117 result = parser.extract_tool_calls(text, mock_request)
118 assert result.tools_called
119 assert len(result.tool_calls) == 2
120 assert json.loads(result.tool_calls[0].function.arguments) == {"city": "SF"}
121 assert json.loads(result.tool_calls[1].function.arguments) == {"city": "NYC"}
122
123 def test_content_before_tool_call(self, mock_tokenizer, mock_request):
124 text = "Let me check. " + _func_calls(
125 _invoke("search", _param("q", "true", "vllm")),
126 )
127 parser = DeepSeekV32Parser(mock_tokenizer)
128 result = parser.extract_tool_calls(text, mock_request)
129 assert result.tools_called
130 assert result.content is not None
131 assert "Let me check" in result.content
132
133 def test_non_string_params_json_parsed(self, mock_tokenizer, mock_request):
134 text = _func_calls(
135 _invoke(
136 "toggle",
137 _param("enabled", "false", "true"),
138 _param("count", "false", "42"),
139 ),
140 )
141 parser = DeepSeekV32Parser(mock_tokenizer)
142 result = parser.extract_tool_calls(text, mock_request)
143 args = json.loads(result.tool_calls[0].function.arguments)
144 assert args["enabled"] is True
145 assert args["count"] == 42
146
147 def test_wrapper_unwrapping(self, mock_tokenizer, mock_request):
148 tool = _make_tool("get_weather", {"location": {"type": "string"}})
149 mock_request.tools = [tool]
150 text = _func_calls(
151 _invoke(
152 "get_weather",
153 _param("arguments", "false", '{"location":"Beijing"}'),
154 ),
155 )
156 parser = DeepSeekV32Parser(mock_tokenizer, tools=[tool])
157 result = parser.extract_tool_calls(text, mock_request)
158 args = json.loads(result.tool_calls[0].function.arguments)
159 assert args == {"location": "Beijing"}
160
161
162 # ── Initial state ────────────────────────────────────────────────────
163
164
165 class TestInitialState:
166 def test_always_content(self, mock_tokenizer):
167 parser = DeepSeekV32Parser(mock_tokenizer)
168 cfg = parser.parser_engine_config
169 assert cfg.initial_state == ParserState.CONTENT
170
171 def test_ignores_thinking_kwargs(self, mock_tokenizer):
172 parser = DeepSeekV32Parser(
173 mock_tokenizer,
174 chat_template_kwargs={"thinking": True, "enable_thinking": True},
175 )
176 cfg = parser.parser_engine_config
177 assert cfg.initial_state == ParserState.CONTENT
178
179
180 # ── Streaming ────────────────────────────────────────────────────────
181
182
183 class TestStreaming:
184 def test_split_parameter_start_is_buffered(self, mock_tokenizer, mock_request):
185 chunks = [
186 DSML_FUNC_START,
187 f"{DSML_INVOKE_PREFIX}edit{DSML_INVOKE_NAME_END}",
188 f"<{_PARAM_OPEN.format(name='expr', is_str='true')}a<b><|DSML|parameter",
189 ' name="date" string="true">tomorrow',
190 _PARAM_CLOSE,
191 DSML_INVOKE_END,
192 DSML_FUNC_END,
193 ]
194
195 results = simulate_tool_streaming(
196 DeepSeekV32Parser(mock_tokenizer), mock_request, chunks
197 )
198
199 arguments = collect_tool_arguments(results)
200 assert "<|DSML|parameter" not in arguments
201 assert json.loads(arguments) == {"expr": "a<b>", "date": "tomorrow"}
202
203 def test_single_tool_streaming(self, mock_tokenizer, mock_request):
204 text = _func_calls(
205 _invoke("get_weather", _param("city", "true", "SF")),
206 )
207 parser = DeepSeekV32Parser(mock_tokenizer)
208 results = simulate_tool_streaming(parser, mock_request, list(text))
209 assert collect_function_name(results) == "get_weather"
210 args_json = collect_tool_arguments(results)
211 assert json.loads(args_json) == {"city": "SF"}
212
213 def test_content_before_tool_streaming(self, mock_tokenizer, mock_request):
214 text = "Checking... " + _func_calls(
215 _invoke("fn", _param("k", "true", "v")),
216 )
217 parser = DeepSeekV32Parser(mock_tokenizer)
218 results = simulate_tool_streaming(parser, mock_request, list(text))
219 content = collect_content(results)
220 assert "Checking" in content
221
222 def test_parallel_tools_streaming(self, mock_tokenizer, mock_request):
223 text = _func_calls(
224 _invoke("fn_a", _param("x", "true", "1")),
225 _invoke("fn_b", _param("y", "true", "2")),
226 )
227 parser = DeepSeekV32Parser(mock_tokenizer)
228 results = simulate_tool_streaming(parser, mock_request, list(text))
229
230 names = []
231 for delta, _ in results:
232 if delta and delta.tool_calls:
233 for tc in delta.tool_calls:
234 if tc.function and tc.function.name:
235 names.append(tc.function.name)
236 assert "fn_a" in names
237 assert "fn_b" in names
238
239 def test_no_tool_content_only(self, mock_tokenizer, mock_request):
240 text = "Just some text, no tools."
241 parser = DeepSeekV32Parser(mock_tokenizer)
242 results = simulate_tool_streaming(parser, mock_request, list(text))
243 content = collect_content(results)
244 assert "Just some text" in content
245 args = collect_tool_arguments(results)
246 assert args == ""
247
248 def test_streaming_wrapper_unwrap_consistency(self, mock_tokenizer, mock_request):
249 tool = _make_tool("get_weather", {"location": {"type": "string"}})
250 mock_request.tools = [tool]
251 parser = DeepSeekV32Parser(mock_tokenizer, tools=[tool])
252
253 chunks = [
254 DSML_FUNC_START,
255 _invoke(
256 "get_weather",
257 _param("arguments", "false", '{"location": "NYC"}'),
258 ),
259 DSML_FUNC_END,
260 ]
261
262 results = simulate_tool_streaming(parser, mock_request, chunks)
263 streamed_args = collect_tool_arguments(results)
264
265 final_delta, _ = results[-1]
266 finish_delta = parser.finish_streaming()
267 extracted = parser._build_extracted_result(final_delta, finish_delta)
268
269 assert extracted.tools_called is True
270 assert len(extracted.tool_calls) == 1
271 final_args = extracted.tool_calls[0].function.arguments
272 assert json.loads(final_args) == {"location": "NYC"}
273 assert '"arguments"' not in streamed_args
274 assert final_args.startswith(streamed_args)
275
276 def test_missing_invoke_end(self, mock_tokenizer, mock_request):
277 text = (
278 f"{DSML_FUNC_START}\n"
279 f"{DSML_INVOKE_PREFIX}fn{DSML_INVOKE_NAME_END}\n"
280 f"{_param('k', 'true', 'v')}\n"
281 f"{DSML_FUNC_END}"
282 )
283 parser = DeepSeekV32Parser(mock_tokenizer)
284 results = simulate_tool_streaming(parser, mock_request, list(text))
285 assert collect_function_name(results) == "fn"
286 args = json.loads(collect_tool_arguments(results))
287 assert args == {"k": "v"}
288
289
290 class TestMissingFunctionCallsWrapper:
291 """An invoke without the ``<|DSML|function_calls>`` wrapper is still a
292 tool call (V3.2 counterpart of #48931)."""
293
294 def test_non_streaming(self, mock_tokenizer, mock_request):
295 text = _invoke("fn", _param("k", "true", "v"))
296 parser = DeepSeekV32Parser(mock_tokenizer)
297 result = parser.extract_tool_calls(text, mock_request)
298
299 assert result.tools_called is True
300 assert result.tool_calls[0].function.name == "fn"
301 assert json.loads(result.tool_calls[0].function.arguments) == {"k": "v"}
302 assert result.content is None
303
304 def test_streaming(self, mock_tokenizer, mock_request):
305 text = "Sure.\n" + _invoke("fn", _param("k", "true", "v"))
306 parser = DeepSeekV32Parser(mock_tokenizer)
307 results = simulate_tool_streaming(parser, mock_request, list(text))
308
309 assert collect_function_name(results) == "fn"
310 assert json.loads(collect_tool_arguments(results)) == {"k": "v"}
311 assert "DSML" not in collect_content(results)