[Frontend] [Parser] Port DeepSeek V4 to streaming parser engine framework (#45877)
Signed-off-by: Ben Browning <bbrownin@redhat.com>
Ben Browning committed
Jul 3, 2026 at 20:55 UTC
fb5291b35b0b1b1ec5840438da0f62f127417710
22 files changed
+1900
-671
tests/parser/engine/test_deepseek_v32.py
new
+268
@@ -0,0 +1,268 @@
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_single_tool_streaming(self, mock_tokenizer, mock_request):
185
+ text = _func_calls(
186
+ _invoke("get_weather", _param("city", "true", "SF")),
187
+ )
188
+ parser = DeepSeekV32Parser(mock_tokenizer)
189
+ results = simulate_tool_streaming(parser, mock_request, list(text))
190
+ assert collect_function_name(results) == "get_weather"
191
+ args_json = collect_tool_arguments(results)
192
+ assert json.loads(args_json) == {"city": "SF"}
193
+
194
+ def test_content_before_tool_streaming(self, mock_tokenizer, mock_request):
195
+ text = "Checking... " + _func_calls(
196
+ _invoke("fn", _param("k", "true", "v")),
197
+ )
198
+ parser = DeepSeekV32Parser(mock_tokenizer)
199
+ results = simulate_tool_streaming(parser, mock_request, list(text))
200
+ content = collect_content(results)
201
+ assert "Checking" in content
202
+
203
+ def test_parallel_tools_streaming(self, mock_tokenizer, mock_request):
204
+ text = _func_calls(
205
+ _invoke("fn_a", _param("x", "true", "1")),
206
+ _invoke("fn_b", _param("y", "true", "2")),
207
+ )
208
+ parser = DeepSeekV32Parser(mock_tokenizer)
209
+ results = simulate_tool_streaming(parser, mock_request, list(text))
210
+
211
+ names = []
212
+ for delta, _ in results:
213
+ if delta and delta.tool_calls:
214
+ for tc in delta.tool_calls:
215
+ if tc.function and tc.function.name:
216
+ names.append(tc.function.name)
217
+ assert "fn_a" in names
218
+ assert "fn_b" in names
219
+
220
+ def test_no_tool_content_only(self, mock_tokenizer, mock_request):
221
+ text = "Just some text, no tools."
222
+ parser = DeepSeekV32Parser(mock_tokenizer)
223
+ results = simulate_tool_streaming(parser, mock_request, list(text))
224
+ content = collect_content(results)
225
+ assert "Just some text" in content
226
+ args = collect_tool_arguments(results)
227
+ assert args == ""
228
+
229
+ def test_streaming_wrapper_unwrap_consistency(self, mock_tokenizer, mock_request):
230
+ tool = _make_tool("get_weather", {"location": {"type": "string"}})
231
+ mock_request.tools = [tool]
232
+ parser = DeepSeekV32Parser(mock_tokenizer, tools=[tool])
233
+
234
+ chunks = [
235
+ DSML_FUNC_START,
236
+ _invoke(
237
+ "get_weather",
238
+ _param("arguments", "false", '{"location": "NYC"}'),
239
+ ),
240
+ DSML_FUNC_END,
241
+ ]
242
+
243
+ results = simulate_tool_streaming(parser, mock_request, chunks)
244
+ streamed_args = collect_tool_arguments(results)
245
+
246
+ final_delta, _ = results[-1]
247
+ finish_delta = parser.finish_streaming()
248
+ extracted = parser._build_extracted_result(final_delta, finish_delta)
249
+
250
+ assert extracted.tools_called is True
251
+ assert len(extracted.tool_calls) == 1
252
+ final_args = extracted.tool_calls[0].function.arguments
253
+ assert json.loads(final_args) == {"location": "NYC"}
254
+ assert '"arguments"' not in streamed_args
255
+ assert final_args.startswith(streamed_args)
256
+
257
+ def test_missing_invoke_end(self, mock_tokenizer, mock_request):
258
+ text = (
259
+ f"{DSML_FUNC_START}\n"
260
+ f"{DSML_INVOKE_PREFIX}fn{DSML_INVOKE_NAME_END}\n"
261
+ f"{_param('k', 'true', 'v')}\n"
262
+ f"{DSML_FUNC_END}"
263
+ )
264
+ parser = DeepSeekV32Parser(mock_tokenizer)
265
+ results = simulate_tool_streaming(parser, mock_request, list(text))
266
+ assert collect_function_name(results) == "fn"
267
+ args = json.loads(collect_tool_arguments(results))
268
+ assert args == {"k": "v"}
tests/parser/engine/test_deepseek_v4.py
new
+922
@@ -0,0 +1,922 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Tests for DeepSeek V4-specific parser engine semantics."""
4
+
5
+import json
6
+
7
+import pytest
8
+
9
+from tests.parser.engine.conftest import make_mock_tokenizer
10
+from tests.parser.engine.replay_harness import (
11
+ DUMMY_TOOLS,
12
+ MockTokenizer,
13
+ _test_request,
14
+ collect_output,
15
+ replay_streaming,
16
+)
17
+from tests.parser.engine.streaming_helpers import (
18
+ collect_content,
19
+ collect_function_name,
20
+ collect_tool_arguments,
21
+ simulate_reasoning_streaming,
22
+ simulate_tool_streaming,
23
+)
24
+from vllm.parser.abstract_parser import DelegatingParser
25
+from vllm.parser.deepseek_v4 import (
26
+ DSML_INVOKE_END,
27
+ DSML_INVOKE_NAME_END,
28
+ DSML_INVOKE_PREFIX,
29
+ DSML_THINK_END,
30
+ DSML_THINK_START,
31
+ DSML_TOOL_END,
32
+ DSML_TOOL_START,
33
+ DeepSeekV4Parser,
34
+ _dsml_arg_converter,
35
+ _unwrap_wrapper_args,
36
+ deepseek_v4_config,
37
+)
38
+from vllm.parser.engine.registered_adapters import (
39
+ DeepSeekV4ParserReasoningAdapter,
40
+ DeepSeekV4ParserToolAdapter,
41
+)
42
+
43
+_THINK_START_ID = 50
44
+_THINK_END_ID = 51
45
+
46
+_PARAM_OPEN = '|DSML|parameter name="{name}" string="{is_str}">'
47
+_PARAM_CLOSE = "</|DSML|parameter>"
48
+
49
+
50
+def _param(name: str, is_str: str, value: str) -> str:
51
+ return f"<{_PARAM_OPEN.format(name=name, is_str=is_str)}{value}{_PARAM_CLOSE}"
52
+
53
+
54
+@pytest.fixture
55
+def mock_tokenizer():
56
+ return make_mock_tokenizer(
57
+ {
58
+ DSML_THINK_START: _THINK_START_ID,
59
+ DSML_THINK_END: _THINK_END_ID,
60
+ }
61
+ )
62
+
63
+
64
+# ── Arg converter unit tests ─────────────────────────────────────────
65
+
66
+
67
+class TestArgConverter:
68
+ def _raw(self, *params: tuple[str, str, str]) -> str:
69
+ lines = [_param(n, s, v) for n, s, v in params]
70
+ return "\n" + "\n".join(lines) + "\n"
71
+
72
+ def test_string_param(self):
73
+ raw = self._raw(("city", "true", "杭州"))
74
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
75
+ assert result == {"city": "杭州"}
76
+
77
+ def test_string_with_spaces_and_quotes(self):
78
+ raw = self._raw(("msg", "true", 'He said "hello world"'))
79
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
80
+ assert result["msg"] == 'He said "hello world"'
81
+
82
+ def test_integer_param(self):
83
+ raw = self._raw(("count", "false", "42"))
84
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
85
+ assert result["count"] == 42
86
+ assert isinstance(result["count"], int)
87
+
88
+ def test_float_param(self):
89
+ raw = self._raw(("ratio", "false", "3.14"))
90
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
91
+ assert abs(result["ratio"] - 3.14) < 1e-9
92
+
93
+ def test_bool_param(self):
94
+ raw = self._raw(("flag", "false", "true"))
95
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
96
+ assert result["flag"] is True
97
+
98
+ def test_array_param(self):
99
+ raw = self._raw(("items", "false", '["a", "b", "c"]'))
100
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
101
+ assert result["items"] == ["a", "b", "c"]
102
+
103
+ def test_object_param(self):
104
+ raw = self._raw(("opts", "false", '{"key": "val"}'))
105
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
106
+ assert result["opts"] == {"key": "val"}
107
+
108
+ def test_mixed_types(self):
109
+ raw = self._raw(
110
+ ("location", "true", "Tokyo"),
111
+ ("limit", "false", "10"),
112
+ ("active", "false", "false"),
113
+ )
114
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
115
+ assert result == {"location": "Tokyo", "limit": 10, "active": False}
116
+
117
+ def test_empty_args(self):
118
+ result = json.loads(_dsml_arg_converter("", partial=False))
119
+ assert result == {}
120
+
121
+ def test_invalid_json_fallback(self):
122
+ raw = self._raw(("data", "false", "[broken"))
123
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
124
+ assert result["data"] == "[broken"
125
+
126
+ def test_chinese_chars_preserved_in_json(self):
127
+ raw = self._raw(("query", "true", "你好世界"))
128
+ raw_json = _dsml_arg_converter(raw, partial=False)
129
+ assert "你好世界" in raw_json
130
+ result = json.loads(raw_json)
131
+ assert result["query"] == "你好世界"
132
+
133
+ def test_partial_complete_plus_in_progress(self):
134
+ raw = self._raw(("city", "true", "Tokyo"))
135
+ raw += f"<{_PARAM_OPEN.format(name='unit', is_str='true')}celsi"
136
+ result = json.loads(_dsml_arg_converter(raw, partial=True))
137
+ assert result["city"] == "Tokyo"
138
+ assert result["unit"] == "celsi"
139
+
140
+ def test_partial_no_in_progress(self):
141
+ raw = self._raw(("city", "true", "Tokyo"))
142
+ result = json.loads(_dsml_arg_converter(raw, partial=True))
143
+ assert result == {"city": "Tokyo"}
144
+
145
+ def test_partial_value_with_angle_bracket(self):
146
+ raw = f"<{_PARAM_OPEN.format(name='code', is_str='true')}a<b"
147
+ result = json.loads(_dsml_arg_converter(raw, partial=True))
148
+ assert result == {"code": "a<b"}
149
+
150
+ def test_partial_value_with_angle_bracket_and_complete_param(self):
151
+ raw = self._raw(("city", "true", "Tokyo"))
152
+ raw += f"<{_PARAM_OPEN.format(name='expr', is_str='true')}x<5"
153
+ result = json.loads(_dsml_arg_converter(raw, partial=True))
154
+ assert result["city"] == "Tokyo"
155
+ assert result["expr"] == "x<5"
156
+
157
+ def test_null_string_false(self):
158
+ raw = self._raw(("val", "false", "null"))
159
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
160
+ assert result["val"] is None
161
+
162
+ def test_string_true_not_json_parsed(self):
163
+ raw = self._raw(("n", "true", "42"))
164
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
165
+ assert result["n"] == "42"
166
+ assert isinstance(result["n"], str)
167
+
168
+
169
+# ── Bare </think> absorption and duplicate <think> absorption ─────────
170
+
171
+
172
+class TestThinkTagAbsorption:
173
+ def test_bare_think_end_not_leaked(self, mock_tokenizer):
174
+ parser = DeepSeekV4Parser(mock_tokenizer)
175
+ chunks = ["</think>", "Here is the direct answer."]
176
+ reasoning, content = simulate_reasoning_streaming(parser, chunks)
177
+ assert reasoning == ""
178
+ assert "</think>" not in content
179
+ assert "Here is the direct answer" in content
180
+
181
+ def test_duplicate_think_start_absorbed(self, mock_tokenizer):
182
+ parser = DeepSeekV4Parser(
183
+ mock_tokenizer, chat_template_kwargs={"thinking": True}
184
+ )
185
+ chunks = [
186
+ "<think>\n",
187
+ "Some reasoning.\n",
188
+ "</think>\n",
189
+ "Answer.",
190
+ ]
191
+ reasoning, content = simulate_reasoning_streaming(parser, chunks)
192
+ assert "Some reasoning" in reasoning
193
+ assert "Answer" in content
194
+
195
+
196
+# ── Missing </|DSML|invoke> before </|DSML|tool_calls> ────────────
197
+
198
+
199
+class TestMissingInvokeEnd:
200
+ def test_non_streaming(self, mock_tokenizer, mock_request):
201
+ parser = DeepSeekV4Parser(mock_tokenizer)
202
+ text = (
203
+ f"{DSML_TOOL_START}"
204
+ f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
205
+ f"{_param('location', 'true', 'NYC')}\n"
206
+ f"{DSML_TOOL_END}"
207
+ )
208
+ result = parser.extract_tool_calls(text, mock_request)
209
+
210
+ assert result.tools_called is True
211
+ assert len(result.tool_calls) == 1
212
+ assert result.tool_calls[0].function.name == "get_weather"
213
+ args = json.loads(result.tool_calls[0].function.arguments)
214
+ assert args == {"location": "NYC"}
215
+
216
+ def test_streaming_with_trailing_content(self, mock_tokenizer, mock_request):
217
+ parser = DeepSeekV4Parser(mock_tokenizer)
218
+ chunks = [
219
+ DSML_TOOL_START,
220
+ f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
221
+ f"{_param('location', 'true', 'NYC')}\n",
222
+ DSML_TOOL_END,
223
+ "Done.",
224
+ ]
225
+
226
+ results = simulate_tool_streaming(parser, mock_request, chunks)
227
+
228
+ assert collect_function_name(results) == "get_weather"
229
+ args = json.loads(collect_tool_arguments(results))
230
+ assert args == {"location": "NYC"}
231
+ assert "Done." in collect_content(results)
232
+
233
+
234
+# ── Thinking mode initial state ──────────────────────────────────────
235
+
236
+
237
+class TestThinkingModeConfig:
238
+ def test_thinking_true_starts_in_reasoning(self):
239
+ cfg = deepseek_v4_config(thinking=True)
240
+ assert cfg.initial_state.name == "REASONING"
241
+
242
+ def test_thinking_false_starts_in_content(self):
243
+ cfg = deepseek_v4_config(thinking=False)
244
+ assert cfg.initial_state.name == "CONTENT"
245
+
246
+ def test_enable_thinking_kwarg(self, mock_tokenizer):
247
+ p = DeepSeekV4Parser(
248
+ mock_tokenizer, chat_template_kwargs={"enable_thinking": True}
249
+ )
250
+ assert p.parser_engine_config.initial_state.name == "REASONING"
251
+
252
+ def test_no_thinking_kwarg_defaults_to_content(self, mock_tokenizer):
253
+ p = DeepSeekV4Parser(mock_tokenizer)
254
+ assert p.parser_engine_config.initial_state.name == "CONTENT"
255
+
256
+ def test_thinking_mode_reasoning_without_tags(self, mock_tokenizer):
257
+ parser = DeepSeekV4Parser(
258
+ mock_tokenizer, chat_template_kwargs={"thinking": True}
259
+ )
260
+ chunks = [
261
+ "\n\nLet me consider ",
262
+ "this carefully.\n",
263
+ "</think>\n",
264
+ "Here is the result.",
265
+ ]
266
+ reasoning, content = simulate_reasoning_streaming(parser, chunks)
267
+ assert "Let me consider" in reasoning
268
+ assert "Here is the result" in content
269
+
270
+ def test_thinking_mode_all_reasoning_no_end_tag(self, mock_tokenizer):
271
+ parser = DeepSeekV4Parser(
272
+ mock_tokenizer, chat_template_kwargs={"thinking": True}
273
+ )
274
+ chunks = ["I'll review ", "the PR."]
275
+ reasoning, content = simulate_reasoning_streaming(parser, chunks)
276
+ assert "review" in reasoning
277
+ assert "the PR" in reasoning
278
+ assert content == ""
279
+
280
+ def test_reasoning_effort_none_overrides_enable_thinking(self, mock_tokenizer):
281
+ p = DeepSeekV4Parser(
282
+ mock_tokenizer,
283
+ chat_template_kwargs={
284
+ "enable_thinking": True,
285
+ "reasoning_effort": "none",
286
+ },
287
+ )
288
+ assert p.parser_engine_config.initial_state.name == "CONTENT"
289
+
290
+
291
+# ── Implicit reasoning end (missing </think> before tool calls) ─────
292
+
293
+
294
+class TestImplicitReasoningEnd:
295
+ """Tool call markers end reasoning implicitly when </think> is missing.
296
+
297
+ DeepSeek V4 models occasionally omit </think> before emitting tool calls.
298
+ The (REASONING, TOOL_START) transition handles this gracefully.
299
+ """
300
+
301
+ @pytest.fixture
302
+ def thinking_parser(self, mock_tokenizer):
303
+ return DeepSeekV4Parser(mock_tokenizer, chat_template_kwargs={"thinking": True})
304
+
305
+ def _reasoning_then_tool(self, reasoning_text: str) -> str:
306
+ return reasoning_text + _tool_calls(
307
+ _invoke("get_weather", ("location", "true", "NYC")),
308
+ )
309
+
310
+ def test_non_streaming_extract_reasoning_implicit_end(self, thinking_parser):
311
+ text = self._reasoning_then_tool("Let me look up the weather.\n\n")
312
+ reasoning, content = thinking_parser.extract_reasoning(text, None)
313
+ assert reasoning == "Let me look up the weather."
314
+ assert DSML_TOOL_START not in reasoning
315
+ assert DSML_INVOKE_PREFIX not in reasoning
316
+ assert content is None
317
+
318
+ def test_non_streaming_extract_tool_calls_implicit_end(
319
+ self, thinking_parser, mock_request
320
+ ):
321
+ text = self._reasoning_then_tool("Let me look up the weather.\n\n")
322
+ result = thinking_parser.extract_tool_calls(text, mock_request)
323
+ assert result.tools_called is True
324
+ assert len(result.tool_calls) == 1
325
+ assert result.tool_calls[0].function.name == "get_weather"
326
+ args = json.loads(result.tool_calls[0].function.arguments)
327
+ assert args == {"location": "NYC"}
328
+
329
+ def test_non_streaming_parse_implicit_end(self, thinking_parser, mock_request):
330
+ text = self._reasoning_then_tool("Let me look up the weather.\n\n")
331
+ reasoning, content, tool_calls = thinking_parser.parse(text, mock_request)
332
+ assert reasoning == "Let me look up the weather."
333
+ assert content is None
334
+ assert tool_calls is not None
335
+ assert len(tool_calls) == 1
336
+ assert tool_calls[0].name == "get_weather"
337
+ args = json.loads(tool_calls[0].arguments)
338
+ assert args == {"location": "NYC"}
339
+
340
+ def test_streaming_reasoning_implicit_end(self, thinking_parser):
341
+ chunks = [
342
+ "Let me look up the weather.\n\n",
343
+ DSML_TOOL_START,
344
+ DSML_INVOKE_PREFIX + "get_weather" + DSML_INVOKE_NAME_END,
345
+ ]
346
+ reasoning, content = simulate_reasoning_streaming(thinking_parser, chunks)
347
+ assert reasoning == "Let me look up the weather."
348
+ assert DSML_TOOL_START not in reasoning
349
+ assert DSML_INVOKE_PREFIX not in reasoning
350
+
351
+ def test_streaming_tool_extraction_implicit_end(
352
+ self, thinking_parser, mock_request
353
+ ):
354
+ chunks = [
355
+ "Let me check.\n\n",
356
+ DSML_TOOL_START,
357
+ DSML_INVOKE_PREFIX
358
+ + "get_weather"
359
+ + DSML_INVOKE_NAME_END
360
+ + "\n"
361
+ + _param("location", "true", "NYC")
362
+ + "\n"
363
+ + DSML_INVOKE_END,
364
+ DSML_TOOL_END,
365
+ ]
366
+ results = simulate_tool_streaming(thinking_parser, mock_request, chunks)
367
+ assert collect_function_name(results) == "get_weather"
368
+ args = json.loads(collect_tool_arguments(results))
369
+ assert args == {"location": "NYC"}
370
+
371
+ def test_thinking_false_explicit_think_then_tool_call(self, mock_tokenizer):
372
+ parser = DeepSeekV4Parser(mock_tokenizer)
373
+ chunks = [
374
+ DSML_THINK_START,
375
+ "Let me check the weather.",
376
+ DSML_TOOL_START,
377
+ DSML_INVOKE_PREFIX + "get_weather" + DSML_INVOKE_NAME_END,
378
+ ]
379
+ reasoning, content = simulate_reasoning_streaming(parser, chunks)
380
+ assert "Let me check the weather" in reasoning
381
+ assert DSML_TOOL_START not in reasoning
382
+ assert DSML_THINK_START not in reasoning
383
+
384
+ def test_non_streaming_parallel_tools_after_implicit_end(
385
+ self, thinking_parser, mock_request
386
+ ):
387
+ text = "I need both.\n\n" + _tool_calls(
388
+ _invoke("get_weather", ("location", "true", "NYC")),
389
+ _invoke("get_time", ("timezone", "true", "EST")),
390
+ )
391
+ result = thinking_parser.extract_tool_calls(text, mock_request)
392
+ assert result.tools_called is True
393
+ assert len(result.tool_calls) == 2
394
+ assert result.tool_calls[0].function.name == "get_weather"
395
+ assert result.tool_calls[1].function.name == "get_time"
396
+
397
+ def test_streaming_implicit_end_trailing_whitespace_stripped(self, thinking_parser):
398
+ chunks = [
399
+ "Reasoning.\n\n\n",
400
+ DSML_TOOL_START,
401
+ DSML_INVOKE_PREFIX + "func" + DSML_INVOKE_NAME_END,
402
+ ]
403
+ reasoning, content = simulate_reasoning_streaming(thinking_parser, chunks)
404
+ assert reasoning == "Reasoning."
405
+
406
+
407
+# ── Wrapper argument unwrapping ──────────────────────────────────────
408
+
409
+
410
+class TestWrapperUnwrapping:
411
+ def test_unwrap_arguments_wrapper(self):
412
+ from vllm.entrypoints.openai.chat_completion.protocol import (
413
+ ChatCompletionToolsParam,
414
+ )
415
+
416
+ tool = ChatCompletionToolsParam(
417
+ type="function",
418
+ function={
419
+ "name": "get_weather",
420
+ "parameters": {
421
+ "type": "object",
422
+ "properties": {"location": {"type": "string"}},
423
+ },
424
+ },
425
+ )
426
+
427
+ result = _unwrap_wrapper_args(
428
+ '{"arguments": {"location": "Beijing"}}',
429
+ [tool],
430
+ "get_weather",
431
+ )
432
+ assert json.loads(result) == {"location": "Beijing"}
433
+
434
+ def test_unwrap_input_wrapper(self):
435
+ from vllm.entrypoints.openai.chat_completion.protocol import (
436
+ ChatCompletionToolsParam,
437
+ )
438
+
439
+ tool = ChatCompletionToolsParam(
440
+ type="function",
441
+ function={
442
+ "name": "get_weather",
443
+ "parameters": {
444
+ "type": "object",
445
+ "properties": {"location": {"type": "string"}},
446
+ },
447
+ },
448
+ )
449
+
450
+ result = _unwrap_wrapper_args(
451
+ '{"input": {"location": "Beijing"}}',
452
+ [tool],
453
+ "get_weather",
454
+ )
455
+ assert json.loads(result) == {"location": "Beijing"}
456
+
457
+ def test_no_unwrap_when_key_in_schema(self):
458
+ from vllm.entrypoints.openai.chat_completion.protocol import (
459
+ ChatCompletionToolsParam,
460
+ )
461
+
462
+ tool = ChatCompletionToolsParam(
463
+ type="function",
464
+ function={
465
+ "name": "func",
466
+ "parameters": {
467
+ "type": "object",
468
+ "properties": {"arguments": {"type": "string"}},
469
+ },
470
+ },
471
+ )
472
+
473
+ result = _unwrap_wrapper_args(
474
+ '{"arguments": "some value"}',
475
+ [tool],
476
+ "func",
477
+ )
478
+ assert json.loads(result) == {"arguments": "some value"}
479
+
480
+ def test_no_unwrap_when_no_tools(self):
481
+ result = _unwrap_wrapper_args(
482
+ '{"arguments": {"location": "Beijing"}}',
483
+ None,
484
+ "get_weather",
485
+ )
486
+ assert json.loads(result) == {"arguments": {"location": "Beijing"}}
487
+
488
+ def test_unwrap_json_string_inner(self):
489
+ from vllm.entrypoints.openai.chat_completion.protocol import (
490
+ ChatCompletionToolsParam,
491
+ )
492
+
493
+ tool = ChatCompletionToolsParam(
494
+ type="function",
495
+ function={
496
+ "name": "get_weather",
497
+ "parameters": {
498
+ "type": "object",
499
+ "properties": {"location": {"type": "string"}},
500
+ },
501
+ },
502
+ )
503
+
504
+ result = _unwrap_wrapper_args(
505
+ '{"arguments": "{\\"location\\": \\"Beijing\\"}"}',
506
+ [tool],
507
+ "get_weather",
508
+ )
509
+ assert json.loads(result) == {"location": "Beijing"}
510
+
511
+
512
+# ── Parallel tool call wrapper unwrapping ───────────────────────────
513
+
514
+
515
+def _make_tool(name, properties):
516
+ from vllm.entrypoints.openai.chat_completion.protocol import ( # noqa: E501
517
+ ChatCompletionToolsParam,
518
+ )
519
+
520
+ return ChatCompletionToolsParam(
521
+ type="function",
522
+ function={
523
+ "name": name,
524
+ "parameters": {
525
+ "type": "object",
526
+ "properties": properties,
527
+ },
528
+ },
529
+ )
530
+
531
+
532
+def _invoke(name, *params):
533
+ body = "\n".join(_param(n, s, v) for n, s, v in params)
534
+ return (
535
+ f"{DSML_INVOKE_PREFIX}{name}{DSML_INVOKE_NAME_END}\n{body}\n{DSML_INVOKE_END}"
536
+ )
537
+
538
+
539
+def _tool_calls(*invokes):
540
+ return DSML_TOOL_START + "\n".join(invokes) + DSML_TOOL_END
541
+
542
+
543
+class TestParallelUnwrapping:
544
+ @pytest.fixture
545
+ def weather_tool(self):
546
+ return _make_tool(
547
+ "get_weather",
548
+ {
549
+ "location": {"type": "string"},
550
+ "unit": {"type": "string"},
551
+ },
552
+ )
553
+
554
+ @pytest.fixture
555
+ def time_tool(self):
556
+ return _make_tool(
557
+ "get_time",
558
+ {"timezone": {"type": "string"}},
559
+ )
560
+
561
+ @pytest.mark.parametrize(
562
+ "weather_args, expected",
563
+ [
564
+ (
565
+ '{"location": "NYC", "unit": "celsius"}',
566
+ {"location": "NYC", "unit": "celsius"},
567
+ ),
568
+ ('{"location": "NYC"}', {"location": "NYC"}),
569
+ ],
570
+ ids=["all_props", "subset_props"],
571
+ )
572
+ def test_unwrap_parallel_uses_correct_schema(
573
+ self,
574
+ mock_tokenizer,
575
+ mock_request,
576
+ weather_tool,
577
+ time_tool,
578
+ weather_args,
579
+ expected,
580
+ ):
581
+ tools = [weather_tool, time_tool]
582
+ parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
583
+ mock_request.tools = tools
584
+
585
+ text = _tool_calls(
586
+ _invoke("get_weather", ("arguments", "false", weather_args)),
587
+ _invoke("get_time", ("timezone", "true", "EST")),
588
+ )
589
+
590
+ result = parser.extract_tool_calls(text, mock_request)
591
+
592
+ assert result.tools_called is True
593
+ assert len(result.tool_calls) == 2
594
+ assert result.tool_calls[0].function.name == "get_weather"
595
+ args0 = json.loads(result.tool_calls[0].function.arguments)
596
+ assert args0 == expected
597
+ assert result.tool_calls[1].function.name == "get_time"
598
+ args1 = json.loads(result.tool_calls[1].function.arguments)
599
+ assert args1 == {"timezone": "EST"}
600
+
601
+ def test_unwrap_parallel_streaming(
602
+ self, mock_tokenizer, mock_request, weather_tool, time_tool
603
+ ):
604
+ tools = [weather_tool, time_tool]
605
+ parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
606
+ mock_request.tools = tools
607
+
608
+ chunks = [
609
+ DSML_TOOL_START,
610
+ _invoke(
611
+ "get_weather",
612
+ ("arguments", "false", '{"location": "NYC"}'),
613
+ ),
614
+ _invoke("get_time", ("timezone", "true", "EST")),
615
+ DSML_TOOL_END,
616
+ ]
617
+
618
+ results = simulate_tool_streaming(parser, mock_request, chunks)
619
+ final_delta, _ = results[-1]
620
+ finish_delta = parser.finish_streaming()
621
+ extracted = parser._build_extracted_result(final_delta, finish_delta)
622
+
623
+ assert extracted.tools_called is True
624
+ assert len(extracted.tool_calls) == 2
625
+ args0 = json.loads(extracted.tool_calls[0].function.arguments)
626
+ assert args0 == {"location": "NYC"}
627
+ args1 = json.loads(extracted.tool_calls[1].function.arguments)
628
+ assert args1 == {"timezone": "EST"}
629
+
630
+ def test_no_unwrap_parallel_when_no_match(
631
+ self, mock_tokenizer, mock_request, weather_tool, time_tool
632
+ ):
633
+ tools = [weather_tool, time_tool]
634
+ parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
635
+ mock_request.tools = tools
636
+
637
+ text = _tool_calls(
638
+ _invoke(
639
+ "get_weather",
640
+ ("arguments", "false", '{"unknown_key": "val"}'),
641
+ ),
642
+ _invoke("get_time", ("timezone", "true", "EST")),
643
+ )
644
+
645
+ result = parser.extract_tool_calls(text, mock_request)
646
+
647
+ assert len(result.tool_calls) == 2
648
+ args0 = json.loads(result.tool_calls[0].function.arguments)
649
+ assert args0 == {"arguments": {"unknown_key": "val"}}
650
+ args1 = json.loads(result.tool_calls[1].function.arguments)
651
+ assert args1 == {"timezone": "EST"}
652
+
653
+ def test_unwrap_single_tool_still_works(self, mock_tokenizer, mock_request):
654
+ tool = _make_tool("get_weather", {"location": {"type": "string"}})
655
+ tools = [tool]
656
+ parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
657
+ mock_request.tools = tools
658
+
659
+ text = _tool_calls(
660
+ _invoke(
661
+ "get_weather",
662
+ ("arguments", "false", '{"location": "Beijing"}'),
663
+ ),
664
+ )
665
+
666
+ result = parser.extract_tool_calls(text, mock_request)
667
+
668
+ assert result.tools_called is True
669
+ assert len(result.tool_calls) == 1
670
+ args = json.loads(result.tool_calls[0].function.arguments)
671
+ assert args == {"location": "Beijing"}
672
+
673
+
674
+# ── Streaming wrapper consistency ─────────────────────────────────────
675
+
676
+
677
+class TestStreamingWrapperConsistency:
678
+ """Streamed arg deltas must stay consistent with final extraction
679
+ when wrapper params like 'arguments' are unwrapped."""
680
+
681
+ def test_streaming_wrapper_unwrap_consistency(self, mock_tokenizer, mock_request):
682
+ tool = _make_tool("get_weather", {"location": {"type": "string"}})
683
+ tools = [tool]
684
+ parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
685
+ mock_request.tools = tools
686
+
687
+ chunks = [
688
+ DSML_TOOL_START,
689
+ _invoke(
690
+ "get_weather",
691
+ ("arguments", "false", '{"location": "NYC"}'),
692
+ ),
693
+ DSML_TOOL_END,
694
+ ]
695
+
696
+ results = simulate_tool_streaming(parser, mock_request, chunks)
697
+ streamed_args = collect_tool_arguments(results)
698
+
699
+ final_delta, _ = results[-1]
700
+ finish_delta = parser.finish_streaming()
701
+ extracted = parser._build_extracted_result(final_delta, finish_delta)
702
+
703
+ assert extracted.tools_called is True
704
+ assert len(extracted.tool_calls) == 1
705
+
706
+ final_args = extracted.tool_calls[0].function.arguments
707
+ assert json.loads(final_args) == {"location": "NYC"}
708
+
709
+ assert '"arguments"' not in streamed_args, (
710
+ f"Streamed args should not contain wrapper key, got: {streamed_args!r}"
711
+ )
712
+
713
+ assert final_args.startswith(streamed_args), (
714
+ f"Extracted args {final_args!r} "
715
+ f"should start with streamed args {streamed_args!r}"
716
+ )
717
+
718
+
719
+# ── DelegatingParser: large delta with </think> + tool calls ─────────
720
+
721
+_DSV4_FULL_VOCAB = {
722
+ DSML_THINK_START: 128821,
723
+ DSML_THINK_END: 128822,
724
+ DSML_TOOL_START: 128823,
725
+ DSML_TOOL_END: 128824,
726
+}
727
+
728
+
729
+class _DeepSeekV4Delegating(DelegatingParser):
730
+ reasoning_parser_cls = DeepSeekV4ParserReasoningAdapter
731
+ tool_parser_cls = DeepSeekV4ParserToolAdapter
732
+
733
+
734
+def _dsv4_tokens(
735
+ reasoning: str,
736
+ tool_name: str,
737
+ params: list[tuple[str, str, str]],
738
+) -> list[tuple[int, str]]:
739
+ """Build a token sequence: reasoning + </think> + DSML tool block."""
740
+ tokens: list[tuple[int, str]] = []
741
+ tid = 100
742
+
743
+ for word in reasoning.split(" "):
744
+ prefix = " " if tokens else ""
745
+ tokens.append((tid, prefix + word))
746
+ tid += 1
747
+
748
+ tokens.append((_DSV4_FULL_VOCAB[DSML_THINK_END], DSML_THINK_END))
749
+
750
+ tokens.append((tid, "\n\n"))
751
+ tid += 1
752
+
753
+ tokens.append((_DSV4_FULL_VOCAB[DSML_TOOL_START], DSML_TOOL_START))
754
+
755
+ tokens.append((tid, "\n"))
756
+ tid += 1
757
+
758
+ invoke_prefix_text = f"{DSML_INVOKE_PREFIX}{tool_name}{DSML_INVOKE_NAME_END}"
759
+ tokens.append((tid, invoke_prefix_text))
760
+ tid += 1
761
+
762
+ tokens.append((tid, "\n"))
763
+ tid += 1
764
+
765
+ for name, is_str, value in params:
766
+ param_text = _param(name, is_str, value)
767
+ tokens.append((tid, param_text))
768
+ tid += 1
769
+ tokens.append((tid, "\n"))
770
+ tid += 1
771
+
772
+ tokens.append((tid, DSML_INVOKE_END))
773
+ tid += 1
774
+
775
+ tokens.append((tid, "\n"))
776
+ tid += 1
777
+
778
+ tokens.append((_DSV4_FULL_VOCAB[DSML_TOOL_END], DSML_TOOL_END))
779
+
780
+ return tokens
781
+
782
+
783
+class TestDelegatingParserLargeDelta:
784
+ """Regression: tool calls lost when </think> + DSML arrive in same delta.
785
+
786
+ The DelegatingParser used by the serving layer splits reasoning and
787
+ tool parsing across two separate engine instances. When </think> and
788
+ the entire DSML tool block arrive in a single large streaming delta,
789
+ the content transfer from reasoning adapter to tool adapter must
790
+ preserve the tool call text.
791
+ """
792
+
793
+ @pytest.fixture
794
+ def dsv4_tokens(self):
795
+ return _dsv4_tokens(
796
+ reasoning="The user wants the current weather in Berlin.",
797
+ tool_name="get_weather",
798
+ params=[
799
+ ("location", "true", "Berlin"),
800
+ ("units", "true", "celsius"),
801
+ ],
802
+ )
803
+
804
+ @pytest.fixture
805
+ def dsv4_tokenizer(self, dsv4_tokens):
806
+ return MockTokenizer(
807
+ vocab=dict(_DSV4_FULL_VOCAB),
808
+ tokens=dsv4_tokens,
809
+ )
810
+
811
+ @pytest.mark.parametrize(
812
+ "chunk_size",
813
+ [1, 2, 3, 5, None],
814
+ ids=lambda c: f"chunk={c}",
815
+ )
816
+ def test_tool_calls_extracted_at_all_chunk_sizes(
817
+ self, dsv4_tokenizer, dsv4_tokens, chunk_size
818
+ ):
819
+ parser = _DeepSeekV4Delegating(
820
+ dsv4_tokenizer,
821
+ chat_template_kwargs={"thinking": True},
822
+ )
823
+ deltas = replay_streaming(
824
+ parser,
825
+ dsv4_tokens,
826
+ chunk_size=chunk_size,
827
+ finished_on_last=True,
828
+ tools=DUMMY_TOOLS,
829
+ )
830
+ output = collect_output(deltas)
831
+
832
+ assert "The user wants" in output.reasoning
833
+ assert len(output.tool_calls) == 1, (
834
+ f"Expected 1 tool call but got {len(output.tool_calls)}; "
835
+ f"reasoning={output.reasoning!r}, content={output.content!r}"
836
+ )
837
+ assert output.tool_calls[0]["name"] == "get_weather"
838
+ args = json.loads(output.tool_calls[0]["arguments"])
839
+ assert args == {"location": "Berlin", "units": "celsius"}
840
+
841
+ def test_eos_drop_token_does_not_swallow_tool_calls(self):
842
+ """Tool calls must survive when an EOS DROP token's ID is in
843
+ delta_token_ids but its text is absent from delta_text.
844
+
845
+ At large stream_interval the EOS token ID arrives in the same
846
+ delta as </think> + tool calls but the detokenizer strips the
847
+ EOS text. The scanner's _rebuild_from_anchors defers all text
848
+ after </think> when it can't find the EOS anchor text. The
849
+ reasoning adapter's finish_streaming must flush deferred text
850
+ as content (with skip_tool_parsing), not as tool calls.
851
+ """
852
+ eos_text = "<|end▁of▁sentence|>"
853
+ eos_id = 128801
854
+ vocab = {
855
+ DSML_THINK_START: 128821,
856
+ DSML_THINK_END: 128822,
857
+ eos_text: eos_id,
858
+ }
859
+
860
+ reasoning = "The user wants weather."
861
+ tool_block = (
862
+ "\n\n"
863
+ + DSML_TOOL_START
864
+ + "\n"
865
+ + DSML_INVOKE_PREFIX
866
+ + "get_weather"
867
+ + DSML_INVOKE_NAME_END
868
+ + "\n"
869
+ + _param("location", "true", "Berlin")
870
+ + "\n"
871
+ + DSML_INVOKE_END
872
+ + "\n"
873
+ + DSML_TOOL_END
874
+ )
875
+ # delta_text does NOT include EOS text (detokenizer strips it)
876
+ full_text = reasoning + DSML_THINK_END + tool_block
877
+ # Build token list: word-split reasoning, then special tokens,
878
+ # then word-split tool block content, then EOS.
879
+ # EOS ID is present but its text is NOT in delta_text.
880
+ tokens: list[tuple[int, str]] = []
881
+ tid = 100
882
+ for word in reasoning.split(" "):
883
+ pfx = " " if tokens else ""
884
+ tokens.append((tid, pfx + word))
885
+ tid += 1
886
+ tokens.append((128822, DSML_THINK_END))
887
+ for ch in tool_block:
888
+ tokens.append((tid, ch))
889
+ tid += 1
890
+ tokens.append((eos_id, eos_text))
891
+
892
+ all_ids = [t[0] for t in tokens]
893
+ tokenizer = MockTokenizer(vocab=vocab, tokens=tokens)
894
+ request = _test_request(tools=DUMMY_TOOLS)
895
+
896
+ # All-in-one delta: EOS ID in token_ids but text NOT in
897
+ # delta_text (detokenizer strips EOS). This is the scenario
898
+ # at large stream_interval.
899
+ parser = _DeepSeekV4Delegating(
900
+ tokenizer,
901
+ chat_template_kwargs={"thinking": True},
902
+ )
903
+ deltas = [
904
+ parser.parse_delta(
905
+ full_text,
906
+ all_ids,
907
+ request,
908
+ prompt_token_ids=[],
909
+ finished=True,
910
+ )
911
+ ]
912
+
913
+ output = collect_output(deltas)
914
+
915
+ assert "The user wants" in output.reasoning
916
+ assert len(output.tool_calls) == 1, (
917
+ f"Expected 1 tool call but got {len(output.tool_calls)}; "
918
+ f"reasoning={output.reasoning!r}, content={output.content!r}"
919
+ )
920
+ assert output.tool_calls[0]["name"] == "get_weather"
921
+ args = json.loads(output.tool_calls[0]["arguments"])
922
+ assert args == {"location": "Berlin"}
tests/parser/engine/test_replay.py
+15
-12
@@ -20,6 +20,7 @@ import pytest
20
from tests.parser.engine.replay_harness import (
21
DUMMY_TOOLS,
22
MockTokenizer,
23
+ Sample,
24
_test_request,
25
assert_no_terminal_leakage,
26
assert_parse_output,
@@ -100,6 +101,14 @@ def _discover_parsers() -> list[_ParserInfo]:
101
102
_PARSERS = _discover_parsers()
103
104
+
105
+def _make_parser(parser_cls: type[ParserEngine], tokenizer, sample: Sample, **extra):
106
+ kwargs = dict(extra)
107
+ if sample.chat_template_kwargs:
108
+ kwargs["chat_template_kwargs"] = sample.chat_template_kwargs
109
+ return parser_cls(tokenizer, sample.tools, **kwargs)
110
+
111
+
112
_ENGINE_PARSERS: dict[str, type[ParserEngine]] = {
113
f"{p.name}_engine": p.parser_cls for p in _PARSERS
114
}
@@ -123,7 +132,7 @@ class TestReplayWithHoldback:
132
133
def test_replay(self, parser_cls, sample, terminals, chunk_size, holdback):
134
tokenizer = make_mock_tokenizer(sample)
126
- parser = parser_cls(tokenizer, sample.tools)
135
+ parser = _make_parser(parser_cls, tokenizer, sample)
136
deltas = replay_streaming(
137
parser,
138
sample.tokens,
@@ -160,7 +169,7 @@ class TestTextHoldback:
169
170
def test_replay(self, parser_cls, sample, terminals, delay):
171
tokenizer = make_mock_tokenizer(sample)
163
- parser = parser_cls(tokenizer, sample.tools)
172
+ parser = _make_parser(parser_cls, tokenizer, sample)
173
deltas = replay_with_text_holdback(
174
parser,
175
sample.tokens,
@@ -190,7 +199,7 @@ class TestReplay:
199
200
def test_replay(self, parser_cls, sample, terminals, chunk_size):
201
tokenizer = make_mock_tokenizer(sample)
193
- parser = parser_cls(tokenizer, sample.tools)
202
+ parser = _make_parser(parser_cls, tokenizer, sample)
203
deltas = replay_streaming(
204
parser,
205
sample.tokens,
@@ -227,7 +236,7 @@ class TestDeferralFinish:
236
237
def test_misaligned_last_delta_with_finish(self, parser_cls, sample, tool_end_text):
238
tokenizer = make_mock_tokenizer(sample)
230
- parser = parser_cls(tokenizer, sample.tools)
239
+ parser = _make_parser(parser_cls, tokenizer, sample)
240
241
request = _test_request()
242
@@ -528,10 +537,7 @@ class TestDropTokenReplay:
537
for sample in parser_info.samples:
538
injected = _inject_drop_tokens(sample)
539
tokenizer = make_mock_tokenizer(injected)
531
- parser = parser_info.parser_cls(
532
- tokenizer,
533
- tools=sample.tools,
534
- )
540
+ parser = _make_parser(parser_info.parser_cls, tokenizer, sample)
541
542
results = replay_streaming(
543
parser,
@@ -562,10 +568,7 @@ class TestDropTokenNonStreaming:
568
for sample in parser_info.samples:
569
injected = _inject_drop_tokens(sample)
570
tokenizer = make_mock_tokenizer(injected)
565
- parser = parser_info.parser_cls(
566
- tokenizer,
567
- tools=sample.tools,
568
- )
571
+ parser = _make_parser(parser_info.parser_cls, tokenizer, sample)
572
573
request = _test_request(tools=sample.tools)
574
output = parse_non_streaming(parser, injected, request)
tests/parser/engine/trace_builder.py
+145
-3
@@ -29,6 +29,8 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
29
ChatCompletionToolsParam,
30
)
31
from vllm.parser.engine.registered_adapters import (
32
+ DeepSeekV4Parser,
33
+ DeepSeekV32Parser,
34
Gemma4Parser,
35
Glm47MoeParser,
36
KimiK2Parser,
@@ -644,6 +646,144 @@ def _build_seed_oss(scenario: Scenario, validate: bool = True) -> Sample:
646
return sample
647
648
649
+# ── DeepSeek V4 (DSML tool format) ──────────────────────────────────
650
+
651
+_DSML = "|DSML|"
652
+_DSV4_VOCAB: dict[str, int] = {
653
+ "<think>": 128821,
654
+ "</think>": 128822,
655
+ f"<{_DSML}tool_calls>": 128823,
656
+ f"</{_DSML}tool_calls>": 128824,
657
+}
658
+
659
+
660
+def _dsv4_param_text(key: str, value: Any) -> str:
661
+ is_string = isinstance(value, str)
662
+ if is_string:
663
+ val_str = value
664
+ elif isinstance(value, bool):
665
+ val_str = "true" if value else "false"
666
+ elif isinstance(value, (int, float)):
667
+ val_str = str(value)
668
+ else:
669
+ val_str = json.dumps(value, ensure_ascii=False)
670
+ string_attr = "true" if is_string else "false"
671
+ return (
672
+ f'<{_DSML}parameter name="{key}" string="{string_attr}">'
673
+ f"{val_str}</{_DSML}parameter>\n"
674
+ )
675
+
676
+
677
+def _dsv4_tool_text(tc: ToolCallSpec) -> str:
678
+ parts = [f'<{_DSML}invoke name="{tc.name}">\n']
679
+ for key, value in tc.arguments.items():
680
+ parts.append(_dsv4_param_text(key, value))
681
+ parts.append(f"</{_DSML}invoke>\n")
682
+ return "".join(parts)
683
+
684
+
685
+def _dsml_tool_segs(
686
+ scenario: Scenario,
687
+ tag: str,
688
+) -> list[tuple[str, bool]]:
689
+ if not scenario.tool_calls:
690
+ return []
691
+ parts = ["\n"]
692
+ for tc in scenario.tool_calls:
693
+ parts.append(_dsv4_tool_text(tc))
694
+ return [
695
+ (f"<{_DSML}{tag}>", True),
696
+ ("".join(parts), False),
697
+ (f"</{_DSML}{tag}>", True),
698
+ ]
699
+
700
+
701
+def _dsv4_segments(scenario: Scenario, thinking: bool) -> list[tuple[str, bool]]:
702
+ segs: list[tuple[str, bool]] = []
703
+
704
+ if thinking:
705
+ if scenario.reasoning is not None:
706
+ segs.append((scenario.reasoning, False))
707
+ if scenario.content is not None or scenario.tool_calls:
708
+ segs.append(("</think>", True))
709
+ else:
710
+ if scenario.reasoning is not None:
711
+ segs.append(("<think>", True))
712
+ segs.append((scenario.reasoning, False))
713
+ segs.append(("</think>", True))
714
+
715
+ if scenario.content is not None:
716
+ segs.append((scenario.content, False))
717
+
718
+ segs.extend(_dsml_tool_segs(scenario, "tool_calls"))
719
+ return segs
720
+
721
+
722
+def _build_deepseek_v4(scenario: Scenario, validate: bool = True) -> Sample:
723
+ thinking = scenario.reasoning is not None
724
+ chat_kwargs = {"thinking": True} if thinking else None
725
+
726
+ if thinking:
727
+ expected_reasoning: str | None = scenario.reasoning or ""
728
+ else:
729
+ expected_reasoning = None
730
+
731
+ sample = _make_sample(
732
+ sample_id=f"deepseek_v4-{scenario.id}",
733
+ description=scenario.description,
734
+ vocab=_DSV4_VOCAB,
735
+ segments=_dsv4_segments(scenario, thinking),
736
+ expected_reasoning=expected_reasoning,
737
+ expected_content=_qwen3_expected_content(scenario),
738
+ expected_tool_calls=_expected_tc(scenario),
739
+ tools=_expected_tools(scenario),
740
+ chat_template_kwargs=chat_kwargs,
741
+ )
742
+ if validate:
743
+ kwargs = {}
744
+ if chat_kwargs:
745
+ kwargs["chat_template_kwargs"] = chat_kwargs
746
+ _validate_sample(sample, DeepSeekV4Parser, **kwargs)
747
+ return sample
748
+
749
+
750
+# ── DeepSeek V3.2 (DSML tool format, no reasoning) ──────────────────
751
+
752
+_DSV32_VOCAB: dict[str, int] = {
753
+ f"<{_DSML}function_calls>": 128830,
754
+ f"</{_DSML}function_calls>": 128831,
755
+}
756
+
757
+
758
+def _dsv32_segments(scenario: Scenario) -> list[tuple[str, bool]]:
759
+ segs: list[tuple[str, bool]] = []
760
+
761
+ if scenario.content is not None:
762
+ segs.append((scenario.content, False))
763
+
764
+ segs.extend(_dsml_tool_segs(scenario, "function_calls"))
765
+ return segs
766
+
767
+
768
+def _build_deepseek_v32(scenario: Scenario, validate: bool = True) -> Sample | None:
769
+ if scenario.reasoning is not None:
770
+ return None
771
+
772
+ sample = _make_sample(
773
+ sample_id=f"deepseek_v32-{scenario.id}",
774
+ description=scenario.description,
775
+ vocab=_DSV32_VOCAB,
776
+ segments=_dsv32_segments(scenario),
777
+ expected_reasoning=None,
778
+ expected_content=_qwen3_expected_content(scenario),
779
+ expected_tool_calls=_expected_tc(scenario),
780
+ tools=_expected_tools(scenario),
781
+ )
782
+ if validate:
783
+ _validate_sample(sample, DeepSeekV32Parser)
784
+ return sample
785
+
786
+
787
# ── GLM-4.7 MoE (XML tool format, starts in REASONING) ──────────────
788
789
_GLM47_MOE_VOCAB: dict[str, int] = {
@@ -811,13 +951,15 @@ _KIMI_K2_SCENARIOS = [
951
# ── Registry and public API ──────────────────────────────────────────
952
953
_BUILDERS: dict[str, Any] = {
814
- "qwen3": _build_qwen3,
954
+ "deepseek_v32": _build_deepseek_v32,
955
+ "deepseek_v4": _build_deepseek_v4,
956
"gemma4": _build_gemma4,
957
"minimax_m2": _build_minimax_m2,
958
"nemotron_v3": _build_nemotron_v3,
959
"seed_oss": _build_seed_oss,
960
"glm47_moe": _build_glm47_moe,
961
"kimi_k2": _build_kimi_k2,
962
+ "qwen3": _build_qwen3,
963
}
964
965
@@ -826,10 +968,10 @@ def build_samples(model: str) -> tuple[Sample, ...]:
968
"""Build all scenario samples for a model, self-validated."""
969
builder = _BUILDERS[model]
970
scenarios = _KIMI_K2_SCENARIOS if model == "kimi_k2" else SCENARIOS
829
- return tuple(builder(s) for s in scenarios)
971
+ return tuple(s for s in (builder(sc) for sc in scenarios) if s is not None)
972
973
832
-def build_sample(model: str, scenario: Scenario) -> Sample:
974
+def build_sample(model: str, scenario: Scenario) -> Sample | None:
975
"""Build a single sample for one model + scenario."""
976
return _BUILDERS[model](scenario)
977
tests/reasoning/test_deepseekv3_reasoning_parser.py
+7
-1
@@ -11,6 +11,8 @@ from vllm.reasoning.deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParse
11
from vllm.reasoning.deepseek_v3_reasoning_parser import DeepSeekV3ReasoningParser
12
from vllm.reasoning.identity_reasoning_parser import IdentityReasoningParser
13
14
+pytestmark = pytest.mark.skip_global_cleanup
15
+
16
REASONING_MODEL_NAME = "deepseek-ai/DeepSeek-V3.1"
17
18
@@ -35,9 +37,13 @@ def test_parser_selection(tokenizer, thinking, expected_parser_type):
37
38
39
def test_deepseek_v4_reasoning_parser_alias():
40
+ from vllm.reasoning.deepseek_v4_engine_reasoning_parser import (
41
+ DeepSeekV4ParserReasoningAdapter,
42
+ )
43
+
44
parser_cls = ReasoningParserManager.get_reasoning_parser("deepseek_v4")
45
40
- assert parser_cls is DeepSeekV3ReasoningParser
46
+ assert parser_cls is DeepSeekV4ParserReasoningAdapter
47
48
49
def test_identity_reasoning_parser_basic(tokenizer):
tests/tool_parsers/test_deepseekv32_tool_parser.py
+37
-51
@@ -1,7 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-"""Unit tests for DeepSeekV32ToolParser.
4
+"""Unit tests for DeepSeekV32EngineToolParser.
5
6
These tests use a minimal mock tokenizer so no real model weights are required.
7
"""
@@ -17,7 +17,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
17
ChatCompletionToolsParam,
18
FunctionDefinition,
19
)
20
-from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser
20
+from vllm.tool_parsers.deepseekv32_engine_tool_parser import (
21
+ DeepSeekV32EngineToolParser,
22
+)
23
+
24
+pytestmark = pytest.mark.skip_global_cleanup
25
26
# ---------------------------------------------------------------------------
27
# Helpers
@@ -30,8 +34,8 @@ MOCK_TOKENIZER.get_vocab.return_value = {}
34
MOCK_TOKENIZER.tokenize.return_value = []
35
36
33
-def make_parser(tools=None) -> DeepSeekV32ToolParser:
34
- return DeepSeekV32ToolParser(MOCK_TOKENIZER, tools=tools)
37
+def make_parser(tools=None) -> DeepSeekV32EngineToolParser:
38
+ return DeepSeekV32EngineToolParser(MOCK_TOKENIZER, tools=tools)
39
40
41
def make_tool_param(name: str, params: dict) -> MagicMock:
@@ -167,9 +171,9 @@ class TestExtractToolCalls:
171
assert isinstance(args["enabled"], bool)
172
assert isinstance(args["count"], int)
173
170
- def test_string_attr_true_preserves_literal_despite_schema(self):
171
- """string="true" must keep the value as a string even
172
- if the schema says integer."""
174
+ def test_string_attr_true_coerced_by_schema(self):
175
+ """string="true" delivers a string, but the engine's schema-aware
176
+ type fixer coerces it to the schema type (integer)."""
177
tool = ChatCompletionToolsParam(
178
function=FunctionDefinition(
179
name="score",
@@ -192,8 +196,8 @@ class TestExtractToolCalls:
196
result = parser.extract_tool_calls(model_output, None)
197
assert result.tools_called
198
args = json.loads(result.tool_calls[0].function.arguments)
195
- assert args == {"value": "42"}
196
- assert isinstance(args["value"], str)
199
+ assert args == {"value": 42}
200
+ assert isinstance(args["value"], int)
201
202
def test_string_attr_false_allows_schema_conversion(self):
203
"""string="false" allows the parser to convert via the tool schema."""
@@ -222,7 +226,6 @@ class TestExtractToolCalls:
226
assert args == {"value": 42}
227
assert isinstance(args["value"], int)
228
225
- @pytest.mark.skip_global_cleanup
229
def test_composed_schema_converts_object_and_array_params(self):
230
"""Composed JSON Schema types must still drive DSML type coercion."""
231
tool = ChatCompletionToolsParam(
@@ -282,8 +285,9 @@ class TestExtractToolCalls:
285
assert isinstance(args["wait"], dict)
286
assert isinstance(args["patches"], list)
287
285
- @pytest.mark.skip_global_cleanup
286
- def test_string_attr_true_preserves_literal_for_composed_schema(self):
288
+ def test_string_attr_true_coerced_by_composed_schema(self):
289
+ """string="true" delivers a JSON string, but the engine's schema-aware
290
+ type fixer coerces it to the composed schema type (object)."""
291
tool = ChatCompletionToolsParam(
292
function=FunctionDefinition(
293
name="set_timer",
@@ -313,7 +317,7 @@ class TestExtractToolCalls:
317
result = parser.extract_tool_calls(model_output, None)
318
assert result.tools_called
319
args = json.loads(result.tool_calls[0].function.arguments)
316
- assert args == {"wait": '{"type":"for","minutes":2880}'}
320
+ assert args == {"wait": {"type": "for", "minutes": 2880}}
321
322
def test_arguments_wrapper_repaired(self):
323
"""A single 'arguments' wrapper parameter must be unwrapped when it
@@ -486,8 +490,9 @@ class TestExtractToolCalls:
490
args = json.loads(result.tool_calls[0].function.arguments)
491
assert args["value"] is None
492
489
- def test_null_not_coerced_without_null_in_schema(self):
490
- """Literal 'null' must stay as a string when the schema is just 'string'."""
493
+ def test_null_coerced_back_to_string_by_schema(self):
494
+ """string="false" with 'null' is json-parsed to None, but the
495
+ engine's schema fixer coerces it back to "null" for string schemas."""
496
tool = ChatCompletionToolsParam(
497
function=FunctionDefinition(
498
name="echo",
@@ -512,8 +517,8 @@ class TestExtractToolCalls:
517
assert args["text"] == "null"
518
assert isinstance(args["text"], str)
519
515
- def test_no_schema_keeps_strings(self):
516
- """Without a tool schema, all string='false' params default to string."""
520
+ def test_no_schema_parses_json(self):
521
+ """Without a tool schema, string='false' params are JSON-parsed."""
522
parser = make_parser(tools=None)
523
model_output = (
524
f"{FC_START}\n"
@@ -526,8 +531,8 @@ class TestExtractToolCalls:
531
result = parser.extract_tool_calls(model_output, None)
532
assert result.tools_called
533
args = json.loads(result.tool_calls[0].function.arguments)
529
- assert args["count"] == "42"
530
- assert args["flag"] == "true"
534
+ assert args["count"] == 42
535
+ assert args["flag"] is True
536
537
538
# ---------------------------------------------------------------------------
@@ -648,8 +653,9 @@ class TestExtractToolCallsStreaming:
653
args_str = self._reconstruct_args(deltas)
654
assert json.loads(args_str) == {"x": 3, "y": 4}
655
651
- def test_string_attr_true_preserves_literal_in_streaming(self):
652
- """Streaming: string='true' must keep the value literal despite schema."""
656
+ def test_string_attr_true_coerced_by_schema_streaming(self):
657
+ """Streaming: string='true' delivers a string but the engine's
658
+ schema fixer coerces it to the schema type (integer)."""
659
tool = ChatCompletionToolsParam(
660
function=FunctionDefinition(
661
name="score",
@@ -672,10 +678,9 @@ class TestExtractToolCallsStreaming:
678
deltas = self._stream(parser, full_text)
679
args_str = self._reconstruct_args(deltas)
680
args = json.loads(args_str)
675
- assert args == {"value": "42"}
676
- assert isinstance(args["value"], str)
681
+ assert args == {"value": 42}
682
+ assert isinstance(args["value"], int)
683
678
- @pytest.mark.skip_global_cleanup
684
def test_composed_schema_conversion_in_streaming(self):
685
tool = ChatCompletionToolsParam(
686
function=FunctionDefinition(
@@ -821,13 +826,13 @@ class TestExtractToolCallsStreaming:
826
assert json.loads(self._reconstruct_args(deltas, tool_index=0)) == {"p": "v1"}
827
assert json.loads(self._reconstruct_args(deltas, tool_index=1)) == {"q": "v2"}
828
824
- def test_state_reset_on_new_stream(self, parser):
825
- """A second stream (previous_text == '') must reset state cleanly."""
829
+ def test_state_reset_on_new_stream(self):
830
+ """A fresh parser instance must produce identical results."""
831
full_text = build_tool_call("fn", {"k": "v"})
832
# First stream
828
- self._stream(parser, full_text)
829
- # Second stream - should produce identical results
830
- deltas2 = self._stream(parser, full_text)
833
+ self._stream(make_parser(), full_text)
834
+ # Second stream with fresh parser
835
+ deltas2 = self._stream(make_parser(), full_text)
836
assert json.loads(self._reconstruct_args(deltas2)) == {"k": "v"}
837
838
def test_empty_arguments_streaming(self, parser):
@@ -860,26 +865,6 @@ class TestExtractToolCallsStreaming:
865
assert len(ids) == 2
866
assert ids[0] != ids[1]
867
863
- def test_eos_after_tool_calls(self, parser):
864
- """EOS token (empty delta_text, non-empty delta_token_ids) returns
865
- a non-None DeltaMessage so the serving framework can finalize."""
866
- full_text = build_tool_call("fn", {"k": "v"})
867
- # Drive through the full text first
868
- deltas = self._stream(parser, full_text)
869
- assert any(d.tool_calls for d in deltas)
870
- # Now simulate EOS: empty delta_text, but token ids present
871
- prev = full_text
872
- result = parser.extract_tool_calls_streaming(
873
- previous_text=prev,
874
- current_text=prev,
875
- delta_text="",
876
- previous_token_ids=[],
877
- current_token_ids=[],
878
- delta_token_ids=[2], # EOS token id
879
- request=make_request(),
880
- )
881
- assert result is not None
882
-
868
def test_streaming_matches_non_streaming(self, parser):
869
"""Streaming and non-streaming must produce the same result."""
870
full_text = build_tool_call(
@@ -968,7 +953,6 @@ class TestExtractToolCallsStreaming:
953
954
def test_emits_arguments_before_invoke_completes(self, parser):
955
"""Argument deltas should stream before the invoke block closes."""
971
- # Stream only a partial invoke (no closing tag)
956
partial_text = (
957
f"{FC_START}\n"
958
f'{INV_START}fn">\n'
@@ -981,7 +965,9 @@ class TestExtractToolCallsStreaming:
965
for tc in delta.tool_calls or []
966
if tc.function and tc.function.arguments is not None
967
]
984
- assert "".join(arg_chunks) == '{"k":"val"'
968
+ combined = "".join(arg_chunks)
969
+ assert combined # some partial args emitted
970
+ assert combined.startswith('{"k"')
971
972
def test_no_marker_leak_chunked(self, parser):
973
"""Chunked streaming must NOT leak DSML start-marker fragments
tests/tool_parsers/test_deepseekv4_tool_parser.py
+71
-8
@@ -1,7 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-"""Unit tests for DeepSeekV4ToolParser."""
4
+"""Unit tests for DeepSeekV4EngineToolParser."""
5
6
import json
7
from unittest.mock import MagicMock
@@ -17,7 +17,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
17
FunctionDefinition,
18
)
19
from vllm.tool_parsers import ToolParserManager
20
-from vllm.tool_parsers.deepseekv4_tool_parser import DeepSeekV4ToolParser
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 = {}
@@ -67,8 +71,8 @@ def sample_tools() -> list[ChatCompletionToolsParam]:
71
]
72
73
70
-def make_parser(tools=None) -> DeepSeekV4ToolParser:
71
- return DeepSeekV4ToolParser(MOCK_TOKENIZER, tools=tools)
74
+def make_parser(tools=None) -> DeepSeekV4EngineToolParser:
75
+ return DeepSeekV4EngineToolParser(MOCK_TOKENIZER, tools=tools)
76
77
78
def make_request(tools=None) -> MagicMock:
@@ -84,7 +88,7 @@ def build_tool_call(func_name: str, params: dict[str, str]) -> str:
88
return f'{TC_START}\n{INV_START}{func_name}">\n{param_strs}{INV_END}\n{TC_END}'
89
90
87
-def stream(parser: DeepSeekV4ToolParser, full_text: str, chunk_size: int = 7):
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):
@@ -120,7 +124,9 @@ def reconstruct_args(deltas, tool_index: int = 0) -> str:
124
125
126
def test_registered():
123
- assert ToolParserManager.get_tool_parser("deepseek_v4") is DeepSeekV4ToolParser
127
+ assert (
128
+ ToolParserManager.get_tool_parser("deepseek_v4") is DeepSeekV4EngineToolParser
129
+ )
130
131
132
def test_extract_tool_calls():
@@ -285,7 +291,7 @@ def test_extract_tool_calls_arguments_wrapper():
291
},
292
)
293
288
- parser = DeepSeekV4ToolParser(mock_tokenizer, tools=[tool])
294
+ parser = DeepSeekV4EngineToolParser(mock_tokenizer, tools=[tool])
295
request = MagicMock()
296
request.tools = [tool]
297
@@ -303,7 +309,64 @@ def test_extract_tool_calls_arguments_wrapper():
309
assert args == {"location": "Beijing"}
310
311
306
-@pytest.mark.skip_global_cleanup
312
+_ANGLE_BRACKET_TOOL = ChatCompletionToolsParam(
313
+ function=FunctionDefinition(
314
+ name="run_command",
315
+ parameters={
316
+ "type": "object",
317
+ "properties": {
318
+ "command": {"type": "string"},
319
+ },
320
+ },
321
+ ),
322
+)
323
+
324
+
325
+@pytest.mark.parametrize(
326
+ "tools",
327
+ [[_ANGLE_BRACKET_TOOL], None],
328
+ ids=["with_tools", "without_tools"],
329
+)
330
+def test_no_dsml_closing_tag_leak_in_streamed_args(tools):
331
+ """Streaming must not leak </|DSML|parameter> into argument values.
332
+
333
+ When a parameter value contains '>' (e.g. shell redirects like
334
+ '2>&1'), certain chunk boundaries cause the incremental lexer to
335
+ emit the closing delimiter text as part of the content token. The
336
+ partial regex then captures it as part of the value, violating the
337
+ prefix invariant and corrupting the streamed JSON.
338
+ """
339
+ full_text = build_tool_call("run_command", {"command": "git --version 2>&1"})
340
+ expected = {"command": "git --version 2>&1"}
341
+
342
+ for chunk_size in range(1, len(full_text) + 1):
343
+ parser = make_parser(tools=tools)
344
+ deltas = stream(parser, full_text, chunk_size=chunk_size)
345
+ args_str = reconstruct_args(deltas)
346
+ assert args_str, f"No args emitted at chunk_size={chunk_size}"
347
+ assert "DSML" not in args_str, (
348
+ f"DSML marker leaked into args at chunk_size={chunk_size}: {args_str!r}"
349
+ )
350
+ parsed = json.loads(args_str)
351
+ assert parsed == expected, (
352
+ f"Args mismatch at chunk_size={chunk_size}: "
353
+ f"got {parsed!r}, expected {expected!r}"
354
+ )
355
+
356
+
357
+def test_non_streaming_extract_with_angle_brackets():
358
+ """Non-streaming extraction must correctly handle '>' in values."""
359
+ parser = make_parser()
360
+ full_text = build_tool_call("run_command", {"command": "git --version 2>&1"})
361
+ result = parser.extract_tool_calls(full_text, make_request())
362
+
363
+ assert result.tools_called
364
+ assert len(result.tool_calls) == 1
365
+ args = json.loads(result.tool_calls[0].function.arguments)
366
+ assert args == {"command": "git --version 2>&1"}
367
+ assert "DSML" not in result.tool_calls[0].function.arguments
368
+
369
+
370
def test_composed_schema_converts_object_and_array_params():
371
tool = ChatCompletionToolsParam(
372
type="function",
tests/tool_parsers/test_structural_tag_registry.py
+6
-4
@@ -16,9 +16,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
16
from vllm.parser.abstract_parser import DelegatingParser
17
from vllm.tool_parsers.abstract_tool_parser import ToolParser
18
from vllm.tool_parsers.deepseekv3_tool_parser import DeepSeekV3ToolParser
19
-from vllm.tool_parsers.deepseekv4_tool_parser import DeepSeekV4ToolParser
19
+from vllm.tool_parsers.deepseekv4_engine_tool_parser import DeepSeekV4EngineToolParser
20
from vllm.tool_parsers.deepseekv31_tool_parser import DeepSeekV31ToolParser
21
-from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser
21
+from vllm.tool_parsers.deepseekv32_engine_tool_parser import (
22
+ DeepSeekV32EngineToolParser,
23
+)
24
from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
25
from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
26
from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser
@@ -185,8 +187,8 @@ def test_get_model_structural_tag_supports_named_tool_choice(
187
[
188
(DeepSeekV3ToolParser, "deepseek_r1"),
189
(DeepSeekV31ToolParser, "deepseek_v3_1"),
188
- (DeepSeekV32ToolParser, "deepseek_v3_2"),
189
- (DeepSeekV4ToolParser, "deepseek_v4"),
190
+ (DeepSeekV32EngineToolParser, "deepseek_v3_2"),
191
+ (DeepSeekV4EngineToolParser, "deepseek_v4"),
192
(Glm47MoeModelToolParser, "glm_4_7"),
193
(Hermes2ProToolParser, "hermes"),
194
(KimiK2ToolParser, "kimi"),
vllm/parser/deepseek_v32.py
new
+131
@@ -0,0 +1,131 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""DeepSeek V3.2 parser: DSML tool calls with ``function_calls`` wrapper.
4
+
5
+DeepSeek V3.2 output format::
6
+
7
+ <|DSML|function_calls>
8
+ <|DSML|invoke name="func_name">
9
+ <|DSML|parameter name="location" string="true">杭州</|DSML|parameter>
10
+ <|DSML|parameter name="count" string="false">5</|DSML|parameter>
11
+ </|DSML|invoke>
12
+ </|DSML|function_calls>
13
+
14
+This is identical to DeepSeek V4 except for the outer wrapper
15
+(``function_calls`` instead of ``tool_calls``) and the absence of
16
+``<think>``/``</think>`` reasoning tags.
17
+"""
18
+
19
+from __future__ import annotations
20
+
21
+import functools
22
+from typing import TYPE_CHECKING
23
+
24
+from vllm.parser.deepseek_v4 import (
25
+ DSML_INVOKE_END,
26
+ DSML_INVOKE_NAME_END,
27
+ DSML_INVOKE_PREFIX,
28
+ DSML_PARAM_CLOSE,
29
+ _dsml_arg_converter,
30
+ _unwrap_wrapper_args,
31
+)
32
+from vllm.parser.engine.events import EventType
33
+from vllm.parser.engine.parser_engine import ParserEngine
34
+from vllm.parser.engine.parser_engine_config import (
35
+ ParserEngineConfig,
36
+ ParserState,
37
+ Transition,
38
+)
39
+
40
+if TYPE_CHECKING:
41
+ from vllm.tokenizers import TokenizerLike
42
+ from vllm.tool_parsers.abstract_tool_parser import Tool
43
+
44
+_DSML = "|DSML|"
45
+
46
+DSML_FUNC_START = f"<{_DSML}function_calls>"
47
+DSML_FUNC_END = f"</{_DSML}function_calls>"
48
+
49
+
50
+@functools.cache
51
+def deepseek_v32_config() -> ParserEngineConfig:
52
+ return ParserEngineConfig(
53
+ name="deepseek_v32",
54
+ initial_state=ParserState.CONTENT,
55
+ terminals={
56
+ "TOOL_START": DSML_FUNC_START,
57
+ "TOOL_END": DSML_FUNC_END,
58
+ "INVOKE_PREFIX": DSML_INVOKE_PREFIX,
59
+ "INVOKE_NAME_END": DSML_INVOKE_NAME_END,
60
+ "INVOKE_END": DSML_INVOKE_END,
61
+ "PARAM_CLOSE": DSML_PARAM_CLOSE,
62
+ },
63
+ token_id_terminals={
64
+ "TOOL_START": DSML_FUNC_START,
65
+ "TOOL_END": DSML_FUNC_END,
66
+ },
67
+ transitions={
68
+ (ParserState.CONTENT, "TOOL_START"): Transition(
69
+ ParserState.TOOL_PREAMBLE,
70
+ (),
71
+ ),
72
+ (ParserState.TOOL_PREAMBLE, "INVOKE_PREFIX"): Transition(
73
+ ParserState.TOOL_NAME,
74
+ (EventType.TOOL_CALL_START,),
75
+ ),
76
+ (ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition(
77
+ ParserState.TOOL_ARGS,
78
+ (),
79
+ ),
80
+ (ParserState.TOOL_ARGS, "INVOKE_END"): Transition(
81
+ ParserState.TOOL_BETWEEN,
82
+ (EventType.TOOL_CALL_END,),
83
+ ),
84
+ (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
85
+ ParserState.CONTENT,
86
+ (EventType.TOOL_CALL_END,),
87
+ ),
88
+ # Parallel tool calls
89
+ (ParserState.TOOL_BETWEEN, "INVOKE_PREFIX"): Transition(
90
+ ParserState.TOOL_NAME,
91
+ (EventType.TOOL_CALL_START,),
92
+ ),
93
+ (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
94
+ ParserState.CONTENT,
95
+ (),
96
+ ),
97
+ },
98
+ content_events={
99
+ ParserState.CONTENT: EventType.TEXT_CHUNK,
100
+ ParserState.TOOL_NAME: EventType.TOOL_NAME,
101
+ ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
102
+ },
103
+ arg_converter=_dsml_arg_converter,
104
+ arg_structural_chars=frozenset(">"),
105
+ strip_content_whitespace_with_tools=False,
106
+ tool_args_json=False,
107
+ )
108
+
109
+
110
+class DeepSeekV32Parser(ParserEngine):
111
+ def __init__(
112
+ self,
113
+ tokenizer: TokenizerLike,
114
+ tools: list[Tool] | None = None,
115
+ **kwargs,
116
+ ) -> None:
117
+ kwargs.pop("chat_template_kwargs", None)
118
+ super().__init__(
119
+ tokenizer,
120
+ tools,
121
+ parser_engine_config=deepseek_v32_config(),
122
+ **kwargs,
123
+ )
124
+ self._arg_converter = self._convert_args
125
+
126
+ def _convert_args(self, raw_args: str, partial: bool) -> str:
127
+ result = _dsml_arg_converter(raw_args, partial)
128
+ if not self._tools:
129
+ return result
130
+ func_name = next((s.name for s in self._tool_slots if s.args == raw_args), None)
131
+ return _unwrap_wrapper_args(result, self._tools, func_name)
vllm/parser/deepseek_v4.py
new
+237
@@ -0,0 +1,237 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""DeepSeek V4 parser: ``<think>``/``</think>``
4
+reasoning plus DSML tool calls in a single state machine.
5
+
6
+DeepSeek V4 output format::
7
+
8
+ <think>
9
+ ...reasoning...
10
+ </think>
11
+ <|DSML|tool_calls>
12
+ <|DSML|invoke name="func_name">
13
+ <|DSML|parameter name="location" string="true">杭州</|DSML|parameter>
14
+ <|DSML|parameter name="count" string="false">5</|DSML|parameter>
15
+ </|DSML|invoke>
16
+ </|DSML|tool_calls>
17
+"""
18
+
19
+from __future__ import annotations
20
+
21
+import contextlib
22
+import functools
23
+import json
24
+from typing import TYPE_CHECKING
25
+
26
+import regex as re
27
+
28
+from vllm.parser.engine.events import EventType
29
+from vllm.parser.engine.parser_engine import ParserEngine
30
+from vllm.parser.engine.parser_engine_config import (
31
+ ParserEngineConfig,
32
+ ParserState,
33
+ Transition,
34
+)
35
+from vllm.tool_parsers.utils import find_tool_properties
36
+
37
+if TYPE_CHECKING:
38
+ from vllm.tokenizers import TokenizerLike
39
+ from vllm.tool_parsers.abstract_tool_parser import Tool
40
+
41
+_DSML = "|DSML|"
42
+
43
+DSML_THINK_START = "<think>"
44
+DSML_THINK_END = "</think>"
45
+DSML_TOOL_START = f"<{_DSML}tool_calls>"
46
+DSML_TOOL_END = f"</{_DSML}tool_calls>"
47
+DSML_INVOKE_PREFIX = f'<{_DSML}invoke name="'
48
+DSML_INVOKE_NAME_END = '">'
49
+DSML_INVOKE_END = f"</{_DSML}invoke>"
50
+DSML_PARAM_CLOSE = f"</{_DSML}parameter>"
51
+
52
+_ESCAPED_DSML = re.escape(_DSML)
53
+_PARAM_RE = re.compile(
54
+ rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
55
+ rf"(.*?)</{_ESCAPED_DSML}parameter>",
56
+ re.DOTALL,
57
+)
58
+_PARTIAL_PARAM_RE = re.compile(
59
+ rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
60
+ rf"(.*)$",
61
+ re.DOTALL,
62
+)
63
+
64
+
65
+def _dsml_arg_converter(raw_args: str, partial: bool) -> str:
66
+ params: dict[str, object] = {}
67
+
68
+ last_end = 0
69
+ for m in _PARAM_RE.finditer(raw_args):
70
+ name, is_str, value = m.group(1), m.group(2), m.group(3)
71
+ if is_str == "true":
72
+ params[name] = value
73
+ else:
74
+ try:
75
+ params[name] = json.loads(value)
76
+ except (json.JSONDecodeError, ValueError):
77
+ params[name] = value
78
+ last_end = m.end()
79
+
80
+ if partial:
81
+ pm = _PARTIAL_PARAM_RE.search(raw_args, last_end)
82
+ if pm:
83
+ name, is_str, value = pm.group(1), pm.group(2), pm.group(3)
84
+ if is_str == "true":
85
+ params[name] = value
86
+ else:
87
+ with contextlib.suppress(json.JSONDecodeError, ValueError):
88
+ params[name] = json.loads(value)
89
+
90
+ return json.dumps(params, ensure_ascii=False)
91
+
92
+
93
+def _unwrap_wrapper_args(
94
+ args_json: str,
95
+ tools: list[Tool] | None,
96
+ func_name: str | None,
97
+) -> str:
98
+ if not tools or not func_name:
99
+ return args_json
100
+ try:
101
+ args = json.loads(args_json)
102
+ except (json.JSONDecodeError, ValueError):
103
+ return args_json
104
+ if not isinstance(args, dict):
105
+ return args_json
106
+ properties = find_tool_properties(tools, func_name)
107
+ if not properties:
108
+ return args_json
109
+ allowed = set(properties.keys())
110
+ for wrapper in ("arguments", "input"):
111
+ if set(args.keys()) != {wrapper} or wrapper in allowed:
112
+ continue
113
+ inner = args[wrapper]
114
+ if isinstance(inner, str):
115
+ try:
116
+ inner = json.loads(inner)
117
+ except json.JSONDecodeError:
118
+ return args_json
119
+ if isinstance(inner, dict) and set(inner.keys()).issubset(allowed):
120
+ return json.dumps(inner, ensure_ascii=False)
121
+ return args_json
122
+
123
+
124
+@functools.cache
125
+def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
126
+ return ParserEngineConfig(
127
+ name="deepseek_v4",
128
+ initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
129
+ terminals={
130
+ "THINK_START": DSML_THINK_START,
131
+ "THINK_END": DSML_THINK_END,
132
+ "TOOL_START": DSML_TOOL_START,
133
+ "TOOL_END": DSML_TOOL_END,
134
+ "INVOKE_PREFIX": DSML_INVOKE_PREFIX,
135
+ "INVOKE_NAME_END": DSML_INVOKE_NAME_END,
136
+ "INVOKE_END": DSML_INVOKE_END,
137
+ "PARAM_CLOSE": DSML_PARAM_CLOSE,
138
+ },
139
+ token_id_terminals={
140
+ "THINK_START": DSML_THINK_START,
141
+ "THINK_END": DSML_THINK_END,
142
+ "TOOL_START": DSML_TOOL_START,
143
+ "TOOL_END": DSML_TOOL_END,
144
+ },
145
+ transitions={
146
+ (ParserState.CONTENT, "THINK_START"): Transition(
147
+ ParserState.REASONING,
148
+ (EventType.REASONING_START,),
149
+ ),
150
+ # Absorb a bare </think> with no prior <think>
151
+ (ParserState.CONTENT, "THINK_END"): Transition(
152
+ ParserState.CONTENT,
153
+ (),
154
+ ),
155
+ # Absorb a duplicate <think> while already reasoning
156
+ (ParserState.REASONING, "THINK_START"): Transition(
157
+ ParserState.REASONING,
158
+ (),
159
+ ),
160
+ (ParserState.REASONING, "THINK_END"): Transition(
161
+ ParserState.CONTENT,
162
+ (EventType.REASONING_END,),
163
+ ),
164
+ # Tool call beginning while still inside <think>
165
+ (ParserState.REASONING, "TOOL_START"): Transition(
166
+ ParserState.TOOL_PREAMBLE,
167
+ (EventType.REASONING_END,),
168
+ ),
169
+ (ParserState.CONTENT, "TOOL_START"): Transition(
170
+ ParserState.TOOL_PREAMBLE,
171
+ (),
172
+ ),
173
+ (ParserState.TOOL_PREAMBLE, "INVOKE_PREFIX"): Transition(
174
+ ParserState.TOOL_NAME,
175
+ (EventType.TOOL_CALL_START,),
176
+ ),
177
+ (ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition(
178
+ ParserState.TOOL_ARGS,
179
+ (),
180
+ ),
181
+ (ParserState.TOOL_ARGS, "INVOKE_END"): Transition(
182
+ ParserState.TOOL_BETWEEN,
183
+ (EventType.TOOL_CALL_END,),
184
+ ),
185
+ (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
186
+ ParserState.CONTENT,
187
+ (EventType.TOOL_CALL_END,),
188
+ ),
189
+ # Parallel tool calls
190
+ (ParserState.TOOL_BETWEEN, "INVOKE_PREFIX"): Transition(
191
+ ParserState.TOOL_NAME,
192
+ (EventType.TOOL_CALL_START,),
193
+ ),
194
+ (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
195
+ ParserState.CONTENT,
196
+ (),
197
+ ),
198
+ },
199
+ content_events={
200
+ ParserState.CONTENT: EventType.TEXT_CHUNK,
201
+ ParserState.REASONING: EventType.REASONING_CHUNK,
202
+ ParserState.TOOL_NAME: EventType.TOOL_NAME,
203
+ ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
204
+ },
205
+ arg_converter=_dsml_arg_converter,
206
+ arg_structural_chars=frozenset(">"),
207
+ strip_content_whitespace_with_tools=False,
208
+ tool_args_json=False,
209
+ )
210
+
211
+
212
+class DeepSeekV4Parser(ParserEngine):
213
+ def __init__(
214
+ self,
215
+ tokenizer: TokenizerLike,
216
+ tools: list[Tool] | None = None,
217
+ **kwargs,
218
+ ) -> None:
219
+ chat_kwargs = kwargs.pop("chat_template_kwargs", None) or {}
220
+ thinking = (
221
+ bool(chat_kwargs.get("thinking") or chat_kwargs.get("enable_thinking"))
222
+ and chat_kwargs.get("reasoning_effort") != "none"
223
+ )
224
+ super().__init__(
225
+ tokenizer,
226
+ tools,
227
+ parser_engine_config=deepseek_v4_config(thinking=thinking),
228
+ **kwargs,
229
+ )
230
+ self._arg_converter = self._convert_args
231
+
232
+ def _convert_args(self, raw_args: str, partial: bool) -> str:
233
+ result = _dsml_arg_converter(raw_args, partial)
234
+ if not self._tools:
235
+ return result
236
+ func_name = next((s.name for s in self._tool_slots if s.args == raw_args), None)
237
+ return _unwrap_wrapper_args(result, self._tools, func_name)
vllm/parser/engine/adapters.py
+2
-1
@@ -111,7 +111,8 @@ class ParserEngineReasoningAdapter(ReasoningParser):
111
return self._parser_engine.reasoning_ended
112
113
def finish_streaming(self) -> DeltaMessage | None:
114
- return self._parser_engine.finish_streaming()
114
+ with self._skip_tool_parsing():
115
+ return self._parser_engine.finish_streaming()
116
117
def get_streaming_fallback_content(
118
self,
vllm/parser/engine/parser_engine.py
+7
-3
@@ -108,7 +108,11 @@ class ParserEngine(Parser):
108
parser_engine_config, tokenizer, vocab=self.vocab
109
)
110
111
- self._reasoning_ended: bool = False
111
+ self._has_reasoning = (
112
+ "THINK_END" in parser_engine_config.token_id_terminals
113
+ or parser_engine_config.initial_state == ParserState.REASONING
114
+ )
115
+ self._reasoning_ended: bool = not self._has_reasoning
116
self._streaming_initialized: bool = False
117
self._prompt_streaming_prepared: bool = False
118
@@ -188,7 +192,7 @@ class ParserEngine(Parser):
192
193
def _reset(self, initial_state: ParserState | None = None) -> None:
194
self._engine.reset(initial_state=initial_state)
191
- self._reasoning_ended = False
195
+ self._reasoning_ended = not self._has_reasoning
196
self._tool_slots.clear()
197
self._deferred_content = ""
198
self._deferred_reasoning = ""
@@ -255,7 +259,7 @@ class ParserEngine(Parser):
259
types = extract_types_from_schema(schema)
260
as_str = json.dumps(value, ensure_ascii=False)
261
coerced = coerce_to_schema_type(as_str, types)
258
- if coerced != value:
262
+ if type(coerced) is not type(value) or coerced != value:
263
return coerced, True
264
return value, False
265
vllm/parser/engine/registered_adapters.py
+12
@@ -7,6 +7,8 @@ names so that :class:`ReasoningParserManager` and
7
:class:`ToolParserManager` can load them lazily.
8
"""
9
10
+from vllm.parser.deepseek_v4 import DeepSeekV4Parser
11
+from vllm.parser.deepseek_v32 import DeepSeekV32Parser
12
from vllm.parser.engine.adapters import make_adapters
13
from vllm.parser.gemma4 import Gemma4Parser
14
from vllm.parser.glm47_moe import Glm47MoeParser
@@ -16,6 +18,16 @@ from vllm.parser.nemotron_v3 import NemotronV3Parser
18
from vllm.parser.qwen3 import Qwen3Parser
19
from vllm.parser.seed_oss import SeedOssParser
20
21
+(
22
+ DeepSeekV32ParserReasoningAdapter,
23
+ DeepSeekV32ParserToolAdapter,
24
+) = make_adapters(DeepSeekV32Parser)
25
+
26
+(
27
+ DeepSeekV4ParserReasoningAdapter,
28
+ DeepSeekV4ParserToolAdapter,
29
+) = make_adapters(DeepSeekV4Parser)
30
+
31
(
32
MinimaxM2ParserReasoningAdapter,
33
MinimaxM2ParserToolAdapter,
vllm/parser/engine/streaming_parser_engine.py
+1
-2
@@ -22,14 +22,13 @@ from vllm.parser.engine.parser_engine_config import (
22
Transition,
23
)
24
from vllm.parser.engine.token_id_scanner import (
25
+ DROP_TERMINAL,
26
LexerInput,
27
PreLexedTerminal,
28
TextChunk,
29
TokenIDScanner,
30
)
31
31
-DROP_TERMINAL = "__DROP__"
32
-
32
33
@dataclass(slots=True)
34
class _DropInfo:
vllm/parser/engine/token_id_scanner.py
+11
-1
@@ -8,6 +8,8 @@ from __future__ import annotations
8
from collections.abc import Sequence
9
from dataclasses import dataclass
10
11
+DROP_TERMINAL = "__DROP__"
12
+
13
14
@dataclass(slots=True)
15
class TextChunk:
@@ -277,7 +279,15 @@ class TokenIDScanner:
279
consumed = pos + len(anchor.text)
280
else:
281
has_later_valid = any(p >= 0 for p in positions[i + 1 :])
280
- if not has_later_valid and consumed < len(delta_text):
282
+ # DROP anchors (EOS, etc.) may have text that never
283
+ # arrives in delta_text (stripped by detokenizer).
284
+ # Don't defer remaining content waiting for text
285
+ # that will never come.
286
+ if (
287
+ not has_later_valid
288
+ and consumed < len(delta_text)
289
+ and anchor.terminal != DROP_TERMINAL
290
+ ):
291
self._deferred_post_text += delta_text[consumed:]
292
consumed = len(delta_text)
293
self._deferred_terminals.append(anchor)
vllm/reasoning/__init__.py
+2
-2
@@ -29,8 +29,8 @@ _REASONING_PARSERS_TO_REGISTER = {
29
"DeepSeekV3ReasoningParser",
30
),
31
"deepseek_v4": (
32
- "deepseek_v3_reasoning_parser",
33
- "DeepSeekV3ReasoningParser",
32
+ "deepseek_v4_engine_reasoning_parser",
33
+ "DeepSeekV4ParserReasoningAdapter",
34
),
35
"poolside_v1": (
36
"poolside_v1_reasoning_parser",
vllm/reasoning/deepseek_v4_engine_reasoning_parser.py
new
+6
@@ -0,0 +1,6 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.parser.engine.registered_adapters import DeepSeekV4ParserReasoningAdapter
5
+
6
+__all__ = ["DeepSeekV4ParserReasoningAdapter"]
vllm/tool_parsers/__init__.py
+4
-4
@@ -31,12 +31,12 @@ _TOOL_PARSERS_TO_REGISTER = {
31
"DeepSeekV31ToolParser",
32
),
33
"deepseek_v32": (
34
- "deepseekv32_tool_parser",
35
- "DeepSeekV32ToolParser",
34
+ "deepseekv32_engine_tool_parser",
35
+ "DeepSeekV32EngineToolParser",
36
),
37
"deepseek_v4": (
38
- "deepseekv4_tool_parser",
39
- "DeepSeekV4ToolParser",
38
+ "deepseekv4_engine_tool_parser",
39
+ "DeepSeekV4EngineToolParser",
40
),
41
"cohere_command3": (
42
"cohere_command_tool_parser",
vllm/tool_parsers/deepseekv32_engine_tool_parser.py
new
+8
@@ -0,0 +1,8 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.parser.engine.registered_adapters import DeepSeekV32ParserToolAdapter
5
+
6
+
7
+class DeepSeekV32EngineToolParser(DeepSeekV32ParserToolAdapter): # type: ignore[valid-type, misc]
8
+ structural_tag_model = "deepseek_v3_2"
vllm/tool_parsers/deepseekv32_tool_parser.py
deleted
-562
@@ -1,562 +0,0 @@
1
-# SPDX-License-Identifier: Apache-2.0
2
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-
4
-import json
5
-import uuid
6
-from collections.abc import Sequence
7
-from typing import Any, Literal
8
-
9
-import regex as re
10
-
11
-from vllm.entrypoints.openai.chat_completion.protocol import (
12
- ChatCompletionRequest,
13
-)
14
-from vllm.entrypoints.openai.engine.protocol import (
15
- DeltaFunctionCall,
16
- DeltaMessage,
17
- DeltaToolCall,
18
- ExtractedToolCallInformation,
19
- FunctionCall,
20
- ToolCall,
21
-)
22
-from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
23
-from vllm.logger import init_logger
24
-from vllm.tokenizers import TokenizerLike
25
-from vllm.tool_parsers.abstract_tool_parser import (
26
- Tool,
27
- ToolParser,
28
-)
29
-from vllm.tool_parsers.utils import (
30
- coerce_to_schema_type,
31
- extract_types_from_schema,
32
- find_tool_properties,
33
- partial_tag_overlap,
34
-)
35
-
36
-logger = init_logger(__name__)
37
-
38
-
39
-class DeepSeekV32ToolParser(ToolParser):
40
- """
41
- example tool call content:
42
- <|DSML|function_calls>
43
- <|DSML|invoke name="get_weather">
44
- <|DSML|parameter name="location" string="true">杭州</|DSML|parameter>
45
- <|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
46
- </|DSML|invoke>
47
- <|DSML|invoke name="get_weather">
48
- <|DSML|parameter name="location" string="true">北京</|DSML|parameter>
49
- <|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
50
- </|DSML|invoke>
51
- </|DSML|function_calls>
52
- """
53
-
54
- tool_call_start_token: str = "<|DSML|function_calls>"
55
- tool_call_end_token: str = "</|DSML|function_calls>"
56
- structural_tag_model = "deepseek_v3_2"
57
-
58
- def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
59
- super().__init__(tokenizer, tools)
60
-
61
- self.prev_tool_call_arr: list[dict] = []
62
-
63
- # Streaming state
64
- self.current_tool_index: int = 0
65
- self._sent_content_idx: int = 0
66
- self._buffer: str = ""
67
- self._in_tool_calls: bool = False
68
- self._active_tool_index: int | None = None
69
- self._active_tool_name: str | None = None
70
- self._active_param_name: str | None = None
71
- self._active_param_string_attr: str | None = None
72
- self._active_param_mode: str | None = None
73
- self._active_param_parts: list[str] = []
74
- self._args_started: list[bool] = []
75
-
76
- # Regex patterns for complete parsing
77
- self.tool_call_complete_regex = re.compile(
78
- re.escape(self.tool_call_start_token)
79
- + r"(.*?)"
80
- + re.escape(self.tool_call_end_token),
81
- re.DOTALL,
82
- )
83
- self.invoke_complete_regex = re.compile(
84
- r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)</|DSML|invoke>', re.DOTALL
85
- )
86
- self.parameter_complete_regex = re.compile(
87
- r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*?)</|DSML|parameter>',
88
- re.DOTALL,
89
- )
90
- self.invoke_start_regex = re.compile(r'<|DSML|invoke\s+name="([^"]+)"\s*>')
91
- self.parameter_start_regex = re.compile(
92
- r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>'
93
- )
94
-
95
- if not self.model_tokenizer:
96
- raise ValueError(
97
- "The model tokenizer must be passed to the ToolParser "
98
- "constructor during construction."
99
- )
100
-
101
- logger.debug(
102
- "vLLM Successfully import tool parser %s !", self.__class__.__name__
103
- )
104
-
105
- def adjust_request(
106
- self, request: ChatCompletionRequest | ResponsesRequest
107
- ) -> ChatCompletionRequest | ResponsesRequest:
108
- request = super().adjust_request(request)
109
- if request.tools and request.tool_choice != "none":
110
- # Ensure tool call tokens
111
- # (e.g. <|DSML|function_calls>, </|DSML|function_calls>)
112
- # are not skippedduring decoding.
113
- # Even though they are not marked as special tokens,
114
- # setting skip_special_tokens=False ensures proper handling in
115
- # transformers 5.x where decoding behavior may have changed.
116
- request.skip_special_tokens = False
117
- return request
118
-
119
- def _generate_tool_call_id(self) -> str:
120
- """Generate a unique tool call ID."""
121
- return f"call_{uuid.uuid4().hex[:24]}"
122
-
123
- def _parse_invoke_params(self, invoke_str: str) -> dict[str, tuple[str, str]]:
124
- param_dict: dict[str, tuple[str, str]] = {}
125
- for param_name, string_attr, param_val in self.parameter_complete_regex.findall(
126
- invoke_str
127
- ):
128
- param_dict[param_name] = (param_val, string_attr)
129
- return param_dict
130
-
131
- @staticmethod
132
- def _repair_param_dict(
133
- param_dict: dict[str, Any],
134
- param_config: dict[str, Any],
135
- ) -> dict[str, Any]:
136
- """Unwrap single 'arguments' / 'input' wrappers when the wrapper
137
- is not part of the requested tool schema and the wrapped object
138
- matches the schema fields."""
139
- allowed = set(param_config.keys())
140
- for wrapper in ("arguments", "input"):
141
- if set(param_dict.keys()) != {wrapper} or wrapper in allowed:
142
- continue
143
- inner = param_dict[wrapper]
144
- if isinstance(inner, str):
145
- try:
146
- inner = json.loads(inner)
147
- except json.JSONDecodeError:
148
- return param_dict
149
- if isinstance(inner, dict) and set(inner.keys()).issubset(allowed):
150
- return inner
151
- return param_dict
152
-
153
- def _convert_params_with_schema(
154
- self,
155
- function_name: str,
156
- param_dict: dict[str, tuple[str, str]],
157
- ) -> dict[str, Any]:
158
- """Convert raw string param values using the tool schema types."""
159
- param_config = find_tool_properties(self.tools, function_name)
160
-
161
- converted: dict[str, Any] = {}
162
- for name, (value, string_attr) in param_dict.items():
163
- if string_attr == "true":
164
- converted[name] = value
165
- continue
166
-
167
- param_types = extract_types_from_schema(param_config.get(name, {}))
168
- converted[name] = coerce_to_schema_type(value, param_types)
169
- return self._repair_param_dict(converted, param_config)
170
-
171
- def _get_param_config(self, function_name: str | None) -> dict[str, Any]:
172
- if not function_name or not self.tools:
173
- return {}
174
- return find_tool_properties(self.tools, function_name)
175
-
176
- @staticmethod
177
- def _json_escape_string_content(text: str) -> str:
178
- return json.dumps(text, ensure_ascii=False)[1:-1]
179
-
180
- def extract_tool_calls(
181
- self,
182
- model_output: str,
183
- request: ChatCompletionRequest,
184
- ) -> ExtractedToolCallInformation:
185
- """Extract tool calls from complete model output (non-streaming)."""
186
- # Quick check
187
- if self.tool_call_start_token not in model_output:
188
- return ExtractedToolCallInformation(
189
- tools_called=False, tool_calls=[], content=model_output
190
- )
191
-
192
- try:
193
- tool_calls = []
194
-
195
- # Find all complete tool_call blocks
196
- for tool_call_match in self.tool_call_complete_regex.findall(model_output):
197
- # Find all invokes within this tool_call
198
- for invoke_name, invoke_content in self.invoke_complete_regex.findall(
199
- tool_call_match
200
- ):
201
- param_dict = self._parse_invoke_params(invoke_content)
202
- params = self._convert_params_with_schema(invoke_name, param_dict)
203
- tool_calls.append(
204
- ToolCall(
205
- type="function",
206
- function=FunctionCall(
207
- name=invoke_name,
208
- arguments=json.dumps(params, ensure_ascii=False),
209
- ),
210
- )
211
- )
212
-
213
- if not tool_calls:
214
- return ExtractedToolCallInformation(
215
- tools_called=False, tool_calls=[], content=model_output
216
- )
217
-
218
- # Extract content before first tool call
219
- first_tool_idx = model_output.find(self.tool_call_start_token)
220
- content = model_output[:first_tool_idx] if first_tool_idx > 0 else None
221
-
222
- return ExtractedToolCallInformation(
223
- tools_called=True, tool_calls=tool_calls, content=content
224
- )
225
-
226
- except Exception:
227
- logger.exception("Error extracting tool calls")
228
- return ExtractedToolCallInformation(
229
- tools_called=False, tool_calls=[], content=model_output
230
- )
231
-
232
- def _reset_streaming_state(self):
233
- """Reset all streaming state."""
234
- self.current_tool_index = 0
235
- self._sent_content_idx = 0
236
- self._buffer = ""
237
- self._in_tool_calls = False
238
- self._active_tool_index = None
239
- self._active_tool_name = None
240
- self._active_param_name = None
241
- self._active_param_string_attr = None
242
- self._active_param_mode = None
243
- self._active_param_parts.clear()
244
- self.prev_tool_call_arr.clear()
245
- self.streamed_args_for_tool.clear()
246
- self._args_started.clear()
247
-
248
- def _add_tool_call_delta(
249
- self,
250
- tool_call_deltas: dict[int, DeltaToolCall],
251
- index: int,
252
- *,
253
- call_id: str | None = None,
254
- call_type: Literal["function"] | None = None,
255
- name: str | None = None,
256
- arguments: str | None = None,
257
- ) -> None:
258
- if arguments:
259
- self.streamed_args_for_tool[index] += arguments
260
-
261
- if index not in tool_call_deltas:
262
- tool_call_deltas[index] = DeltaToolCall(
263
- index=index,
264
- id=call_id,
265
- type=call_type,
266
- function=DeltaFunctionCall(name=name, arguments=arguments),
267
- )
268
- return
269
-
270
- delta = tool_call_deltas[index]
271
- if call_id is not None:
272
- delta.id = call_id
273
- if call_type is not None:
274
- delta.type = call_type
275
- if delta.function is None:
276
- delta.function = DeltaFunctionCall()
277
- if name is not None:
278
- delta.function.name = name
279
- if arguments is not None:
280
- delta.function.arguments = (delta.function.arguments or "") + arguments
281
-
282
- def _begin_streaming_tool_call(
283
- self,
284
- name: str,
285
- tool_call_deltas: dict[int, DeltaToolCall],
286
- ) -> None:
287
- index = self.current_tool_index
288
- self.current_tool_index += 1
289
- self._active_tool_index = index
290
- self._active_tool_name = name
291
- self.prev_tool_call_arr.append({"name": name, "arguments": {}})
292
- self.streamed_args_for_tool.append("")
293
- self._args_started.append(False)
294
- self._add_tool_call_delta(
295
- tool_call_deltas,
296
- index,
297
- call_id=self._generate_tool_call_id(),
298
- call_type="function",
299
- name=name,
300
- arguments="",
301
- )
302
-
303
- def _append_param_prefix(
304
- self,
305
- tool_call_deltas: dict[int, DeltaToolCall],
306
- index: int,
307
- key: str,
308
- *,
309
- as_string: bool,
310
- ) -> None:
311
- prefix = "{" if not self._args_started[index] else ","
312
- self._args_started[index] = True
313
- arguments = prefix + json.dumps(key, ensure_ascii=False) + ":"
314
- if as_string:
315
- arguments += '"'
316
- self._add_tool_call_delta(tool_call_deltas, index, arguments=arguments)
317
-
318
- def _append_json_param_value(
319
- self,
320
- tool_call_deltas: dict[int, DeltaToolCall],
321
- index: int,
322
- key: str,
323
- value: Any,
324
- ) -> None:
325
- self._append_param_prefix(tool_call_deltas, index, key, as_string=False)
326
- self._add_tool_call_delta(
327
- tool_call_deltas,
328
- index,
329
- arguments=json.dumps(value, ensure_ascii=False),
330
- )
331
-
332
- def _param_types_for_name(self, name: str) -> list[str]:
333
- param_config = self._get_param_config(self._active_tool_name)
334
- if name in param_config and isinstance(param_config[name], dict):
335
- return extract_types_from_schema(param_config[name])
336
- return ["string"]
337
-
338
- @staticmethod
339
- def _can_stream_raw_param(param_types: list[str]) -> bool:
340
- # Scalars and unions need the complete value so streaming and
341
- # non-streaming share the same coercion fallback behavior.
342
- return set(param_types).issubset({"object", "array"})
343
-
344
- def _should_buffer_wrapper_param(self, name: str) -> bool:
345
- if (
346
- self._active_tool_index is None
347
- or self._args_started[self._active_tool_index]
348
- ):
349
- return False
350
- param_config = self._get_param_config(self._active_tool_name)
351
- return bool(
352
- param_config and name in ("arguments", "input") and name not in param_config
353
- )
354
-
355
- def _finish_buffered_param(
356
- self,
357
- tool_call_deltas: dict[int, DeltaToolCall],
358
- index: int,
359
- ) -> None:
360
- assert self._active_param_name is not None
361
- assert self._active_param_string_attr is not None
362
- raw_value = "".join(self._active_param_parts)
363
- converted = self._convert_params_with_schema(
364
- self._active_tool_name or "",
365
- {self._active_param_name: (raw_value, self._active_param_string_attr)},
366
- )
367
- for key, value in converted.items():
368
- self._append_json_param_value(tool_call_deltas, index, key, value)
369
-
370
- def _close_streaming_tool_call(
371
- self,
372
- tool_call_deltas: dict[int, DeltaToolCall],
373
- ) -> None:
374
- index = self._active_tool_index
375
- if index is None:
376
- return
377
-
378
- suffix = "}" if self._args_started[index] else "{}"
379
- self._add_tool_call_delta(tool_call_deltas, index, arguments=suffix)
380
- try:
381
- self.prev_tool_call_arr[index] = {
382
- "name": self._active_tool_name,
383
- "arguments": json.loads(self.streamed_args_for_tool[index]),
384
- }
385
- except (json.JSONDecodeError, IndexError):
386
- logger.exception("Failed to finalize DeepSeek DSML streaming tool call")
387
-
388
- self._active_tool_index = None
389
- self._active_tool_name = None
390
- self._active_param_name = None
391
- self._active_param_string_attr = None
392
- self._active_param_mode = None
393
- self._active_param_parts.clear()
394
-
395
- def _process_streaming_buffer(
396
- self,
397
- content_parts: list[str],
398
- tool_call_deltas: dict[int, DeltaToolCall],
399
- ) -> None:
400
- parameter_end_token = "</|DSML|parameter>"
401
- invoke_end_token = "</|DSML|invoke>"
402
-
403
- while True:
404
- if not self._in_tool_calls:
405
- start_idx = self._buffer.find(self.tool_call_start_token)
406
- if start_idx == -1:
407
- overlap = partial_tag_overlap(
408
- self._buffer, self.tool_call_start_token
409
- )
410
- sendable_idx = len(self._buffer) - overlap
411
- if sendable_idx > 0:
412
- content_parts.append(self._buffer[:sendable_idx])
413
- self._buffer = self._buffer[sendable_idx:]
414
- return
415
-
416
- if start_idx > 0:
417
- content_parts.append(self._buffer[:start_idx])
418
- self._buffer = self._buffer[start_idx:]
419
- continue
420
-
421
- self._buffer = self._buffer[len(self.tool_call_start_token) :]
422
- self._in_tool_calls = True
423
- continue
424
-
425
- if self._active_tool_index is None:
426
- stripped_len = len(self._buffer) - len(self._buffer.lstrip())
427
- if stripped_len:
428
- self._buffer = self._buffer[stripped_len:]
429
- continue
430
-
431
- if self._buffer.startswith(self.tool_call_end_token):
432
- self._buffer = self._buffer[len(self.tool_call_end_token) :]
433
- self._in_tool_calls = False
434
- continue
435
-
436
- match = self.invoke_start_regex.match(self._buffer)
437
- if match is None:
438
- return
439
-
440
- self._buffer = self._buffer[match.end() :]
441
- self._begin_streaming_tool_call(match.group(1), tool_call_deltas)
442
- continue
443
-
444
- index = self._active_tool_index
445
-
446
- if self._active_param_mode is not None:
447
- end_pos = self._buffer.find(parameter_end_token)
448
- if end_pos != -1:
449
- raw_content = self._buffer[:end_pos]
450
- self._buffer = self._buffer[end_pos + len(parameter_end_token) :]
451
- if self._active_param_mode in ("wrapper", "buffered"):
452
- self._active_param_parts.append(raw_content)
453
- self._finish_buffered_param(tool_call_deltas, index)
454
- elif self._active_param_mode == "string":
455
- arguments = self._json_escape_string_content(raw_content) + '"'
456
- self._add_tool_call_delta(
457
- tool_call_deltas, index, arguments=arguments
458
- )
459
- else:
460
- self._add_tool_call_delta(
461
- tool_call_deltas, index, arguments=raw_content
462
- )
463
-
464
- self._active_param_name = None
465
- self._active_param_string_attr = None
466
- self._active_param_mode = None
467
- self._active_param_parts.clear()
468
- continue
469
-
470
- overlap = partial_tag_overlap(self._buffer, parameter_end_token)
471
- safe_len = len(self._buffer) - overlap
472
- if safe_len > 0:
473
- raw_content = self._buffer[:safe_len]
474
- self._buffer = self._buffer[safe_len:]
475
- if self._active_param_mode in ("wrapper", "buffered"):
476
- self._active_param_parts.append(raw_content)
477
- elif self._active_param_mode == "string":
478
- self._add_tool_call_delta(
479
- tool_call_deltas,
480
- index,
481
- arguments=self._json_escape_string_content(raw_content),
482
- )
483
- else:
484
- self._add_tool_call_delta(
485
- tool_call_deltas, index, arguments=raw_content
486
- )
487
- return
488
-
489
- stripped_len = len(self._buffer) - len(self._buffer.lstrip())
490
- if stripped_len:
491
- self._buffer = self._buffer[stripped_len:]
492
- continue
493
-
494
- if self._buffer.startswith(invoke_end_token):
495
- self._buffer = self._buffer[len(invoke_end_token) :]
496
- self._close_streaming_tool_call(tool_call_deltas)
497
- continue
498
-
499
- match = self.parameter_start_regex.match(self._buffer)
500
- if match is None:
501
- return
502
-
503
- self._buffer = self._buffer[match.end() :]
504
- name = match.group(1)
505
- string_attr = match.group(2)
506
- self._active_param_name = name
507
- self._active_param_string_attr = string_attr
508
-
509
- if self._should_buffer_wrapper_param(name):
510
- self._active_param_mode = "wrapper"
511
- continue
512
-
513
- if string_attr == "true":
514
- self._append_param_prefix(tool_call_deltas, index, name, as_string=True)
515
- self._active_param_mode = "string"
516
- continue
517
-
518
- param_types = self._param_types_for_name(name)
519
- if not self._can_stream_raw_param(param_types):
520
- self._active_param_mode = "buffered"
521
- continue
522
-
523
- self._append_param_prefix(tool_call_deltas, index, name, as_string=False)
524
- self._active_param_mode = "raw"
525
-
526
- def extract_tool_calls_streaming(
527
- self,
528
- previous_text: str,
529
- current_text: str,
530
- delta_text: str,
531
- previous_token_ids: Sequence[int], # pylint: disable=unused-argument
532
- current_token_ids: Sequence[int], # pylint: disable=unused-argument
533
- delta_token_ids: Sequence[int],
534
- request: ChatCompletionRequest,
535
- ) -> DeltaMessage | None:
536
- """Extract tool calls from streaming model output.
537
-
538
- Buffers DSML markup while streaming tool-call metadata and argument
539
- JSON fragments as soon as they are complete enough to be valid deltas.
540
- """
541
-
542
- # First chunk of a new stream — reset state from prior request.
543
- if not previous_text:
544
- self._reset_streaming_state()
545
-
546
- self._buffer += delta_text
547
- content_parts: list[str] = []
548
- tool_call_deltas: dict[int, DeltaToolCall] = {}
549
- self._process_streaming_buffer(content_parts, tool_call_deltas)
550
-
551
- if content_parts or tool_call_deltas:
552
- content = "".join(content_parts) or None
553
- return DeltaMessage(
554
- content=content, tool_calls=list(tool_call_deltas.values())
555
- )
556
-
557
- # Empty delta with token ids means EOS or closing tag; return
558
- # non-None so the serving framework can finalize finish_reason.
559
- if not delta_text and delta_token_ids and self.prev_tool_call_arr:
560
- return DeltaMessage(content="")
561
-
562
- return None
vllm/tool_parsers/deepseekv4_engine_tool_parser.py
new
+8
@@ -0,0 +1,8 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.parser.engine.registered_adapters import DeepSeekV4ParserToolAdapter
5
+
6
+
7
+class DeepSeekV4EngineToolParser(DeepSeekV4ParserToolAdapter): # type: ignore[valid-type, misc]
8
+ structural_tag_model = "deepseek_v4"
vllm/tool_parsers/deepseekv4_tool_parser.py
deleted
-17
@@ -1,17 +0,0 @@
1
-# SPDX-License-Identifier: Apache-2.0
2
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-
4
-from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser
5
-
6
-
7
-class DeepSeekV4ToolParser(DeepSeekV32ToolParser):
8
- """
9
- DeepSeek V4 DSML tool parser.
10
-
11
- V4 keeps the V3.2 DSML invoke/parameter grammar, but wraps tool calls in
12
- ``<|DSML|tool_calls>`` instead of ``<|DSML|function_calls>``.
13
- """
14
-
15
- tool_call_start_token: str = "<|DSML|tool_calls>"
16
- tool_call_end_token: str = "</|DSML|tool_calls>"
17
- structural_tag_model = "deepseek_v4"