[Bugfix] Implicitly close DeepSeek DSML parameters (#54838)
Signed-off-by: sfeng33 <4florafeng@gmail.com>
Flora Feng committed
Sep 2, 2026 at 02:13 UTC
300f688321564882b6675c4b85b501021418161e
4 files changed
+90
-1
tests/parser/engine/test_deepseek_v32.py
+19
@@ -181,6 +181,25 @@ class TestInitialState:
181
182
183
class TestStreaming:
184
+ def test_split_parameter_start_is_buffered(self, mock_tokenizer, mock_request):
185
+ chunks = [
186
+ DSML_FUNC_START,
187
+ f"{DSML_INVOKE_PREFIX}edit{DSML_INVOKE_NAME_END}",
188
+ f"<{_PARAM_OPEN.format(name='expr', is_str='true')}a<b><|DSML|parameter",
189
+ ' name="date" string="true">tomorrow',
190
+ _PARAM_CLOSE,
191
+ DSML_INVOKE_END,
192
+ DSML_FUNC_END,
193
+ ]
194
+
195
+ results = simulate_tool_streaming(
196
+ DeepSeekV32Parser(mock_tokenizer), mock_request, chunks
197
+ )
198
+
199
+ arguments = collect_tool_arguments(results)
200
+ assert "<|DSML|parameter" not in arguments
201
+ assert json.loads(arguments) == {"expr": "a<b>", "date": "tomorrow"}
202
+
203
def test_single_tool_streaming(self, mock_tokenizer, mock_request):
204
text = _func_calls(
205
_invoke("get_weather", _param("city", "true", "SF")),
tests/parser/engine/test_deepseek_v4.py
+65
@@ -154,6 +154,26 @@ class TestArgConverter:
154
assert result["city"] == "Tokyo"
155
assert result["expr"] == "x<5"
156
157
+ def test_next_parameter_prefix_implicitly_closes_previous(self):
158
+ raw = (
159
+ f"<{_PARAM_OPEN.format(name='city', is_str='true')}Tokyo\n"
160
+ "<|DSML|parameter name="
161
+ )
162
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
163
+ assert result == {"city": "Tokyo\n"}
164
+
165
+ def test_implicit_close_preserves_malformed_text(self):
166
+ raw = (
167
+ f"<{_PARAM_OPEN.format(name='city', is_str='true')}"
168
+ "Tokyo</|DSML|parameter\n"
169
+ f"<{_PARAM_OPEN.format(name='unit', is_str='true')}celsius{_PARAM_CLOSE}"
170
+ )
171
+ result = json.loads(_dsml_arg_converter(raw, partial=False))
172
+ assert result == {
173
+ "city": "Tokyo</|DSML|parameter\n",
174
+ "unit": "celsius",
175
+ }
176
+
177
def test_null_string_false(self):
178
raw = self._raw(("val", "false", "null"))
179
result = json.loads(_dsml_arg_converter(raw, partial=False))
@@ -166,6 +186,51 @@ class TestArgConverter:
186
assert isinstance(result["n"], str)
187
188
189
+class TestImplicitParameterClose:
190
+ def test_non_streaming_recovers_next_parameter(self, mock_tokenizer, mock_request):
191
+ text = (
192
+ f"{DSML_TOOL_START}"
193
+ f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
194
+ f"<{_PARAM_OPEN.format(name='location', is_str='true')}Paris "
195
+ f"<{_PARAM_OPEN.format(name='date', is_str='true')}"
196
+ f"tomorrow{_PARAM_CLOSE}"
197
+ f"{DSML_INVOKE_END}{DSML_TOOL_END}"
198
+ )
199
+
200
+ result = DeepSeekV4Parser(mock_tokenizer).extract_tool_calls(text, mock_request)
201
+
202
+ assert result.tools_called is True
203
+ assert json.loads(result.tool_calls[0].function.arguments) == {
204
+ "location": "Paris ",
205
+ "date": "tomorrow",
206
+ }
207
+
208
+ def test_streaming_split_next_parameter_tag_is_buffered(
209
+ self, mock_tokenizer, mock_request
210
+ ):
211
+ chunks = [
212
+ DSML_TOOL_START,
213
+ f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n",
214
+ f"<{_PARAM_OPEN.format(name='location', is_str='true')}"
215
+ "Paris a<b><|DSML|parameter",
216
+ ' name="date" string="true">tomorrow',
217
+ _PARAM_CLOSE,
218
+ DSML_INVOKE_END,
219
+ DSML_TOOL_END,
220
+ ]
221
+
222
+ results = simulate_tool_streaming(
223
+ DeepSeekV4Parser(mock_tokenizer), mock_request, chunks
224
+ )
225
+
226
+ arguments = collect_tool_arguments(results)
227
+ assert "<|DSML|param" not in arguments
228
+ assert json.loads(arguments) == {
229
+ "location": "Paris a<b>",
230
+ "date": "tomorrow",
231
+ }
232
+
233
+
234
# ── Bare </think> absorption and duplicate <think> absorption ─────────
235
236
vllm/parser/deepseek_v32.py
+2
@@ -26,6 +26,7 @@ from vllm.parser.deepseek_v4 import (
26
DSML_INVOKE_NAME_END,
27
DSML_INVOKE_PREFIX,
28
DSML_PARAM_CLOSE,
29
+ DSML_PARAM_START,
30
_dsml_arg_converter,
31
_unwrap_wrapper_args,
32
)
@@ -58,6 +59,7 @@ def deepseek_v32_config() -> ParserEngineConfig:
59
"INVOKE_PREFIX": DSML_INVOKE_PREFIX,
60
"INVOKE_NAME_END": DSML_INVOKE_NAME_END,
61
"INVOKE_END": DSML_INVOKE_END,
62
+ "PARAM_START": DSML_PARAM_START,
63
"PARAM_CLOSE": DSML_PARAM_CLOSE,
64
},
65
token_id_terminals={
vllm/parser/deepseek_v4.py
+4
-1
@@ -47,12 +47,14 @@ 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_START = f"<{_DSML}parameter"
51
DSML_PARAM_CLOSE = f"</{_DSML}parameter>"
52
53
_ESCAPED_DSML = re.escape(_DSML)
54
_PARAM_RE = re.compile(
55
rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
55
- rf"(.*?)</{_ESCAPED_DSML}parameter>",
56
+ rf"(.*?)"
57
+ rf"(?:</{_ESCAPED_DSML}parameter>|(?=<{_ESCAPED_DSML}parameter\s+name=))",
58
re.DOTALL,
59
)
60
_PARTIAL_PARAM_RE = re.compile(
@@ -134,6 +136,7 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
136
"INVOKE_PREFIX": DSML_INVOKE_PREFIX,
137
"INVOKE_NAME_END": DSML_INVOKE_NAME_END,
138
"INVOKE_END": DSML_INVOKE_END,
139
+ "PARAM_START": DSML_PARAM_START,
140
"PARAM_CLOSE": DSML_PARAM_CLOSE,
141
},
142
token_id_terminals={