[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={