[Frontend] Fix the parsing of missing `string=` in DeepSeek V4 (#56271)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: lazymio <mio@lazym.io>

lazymio committed Sep 18, 2026 at 13:11 UTC 39e33db7f3b10ebd3aca6a0008f2990978f118e7
8 files changed +174 -29
rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs
+28
@@ -193,6 +193,34 @@ mod tests {
193 );
194 }
195
196 + #[test]
197 + fn deepseek_v32_parse_complete_keeps_parameter_without_string_attr() {
198 + // The model sometimes omits `string="..."`; the parameter must still
199 + // be kept and converted through the schema, like `string="false"`.
200 + let mut parser = DeepSeekV32ToolParser::new(&test_tools());
201 + let output = parser
202 + .parse_complete(
203 + "<|DSML|function_calls>\n\
204 + <|DSML|invoke name=\"convert\">\n\
205 + <|DSML|parameter name=\"whole\">5.0</|DSML|parameter>\n\
206 + <|DSML|parameter name=\"flag\" >true</|DSML|parameter>\n\
207 + <|DSML|parameter name=\"payload\" string=\"true\">{\"nested\":true}</|DSML|parameter>\n\
208 + </|DSML|invoke>\n\
209 + </|DSML|function_calls>",
210 + )
211 + .unwrap();
212 +
213 + assert_eq!(output.calls().len(), 1);
214 + assert_eq!(
215 + serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
216 + json!({
217 + "whole": 5.0,
218 + "flag": true,
219 + "payload": "{\"nested\":true}",
220 + })
221 + );
222 + }
223 +
224 #[test]
225 fn deepseek_v32_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
226 let mut parser = DeepSeekV32ToolParser::new(&test_tools());
rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs
+23
@@ -147,6 +147,29 @@ mod tests {
147 );
148 }
149
150 + #[test]
151 + fn deepseek_v4_streaming_keeps_parameter_without_string_attr() {
152 + let mut parser = DeepSeekV4ToolParser::new(&test_tools());
153 + let output = collect_stream(
154 + &mut parser,
155 + &[
156 + "<|DSML|tool_calls>\n<|DSML|invoke name=\"get_weather\">\n",
157 + "<|DSML|parameter name=\"loc",
158 + "ation\">Par",
159 + "is</|DSML|parameter>\n",
160 + "<|DSML|parameter name=\"date\" string=\"true\">tomorrow</|DSML|parameter>\n",
161 + "</|DSML|invoke>\n</|DSML|tool_calls>",
162 + ],
163 + );
164 +
165 + assert!(output.normal_text().is_empty());
166 + assert_eq!(output.calls().len(), 1);
167 + assert_eq!(
168 + serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
169 + json!({ "location": "Paris", "date": "tomorrow" })
170 + );
171 + }
172 +
173 #[test]
174 fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
175 assert_tool_framing_preserves_body_whitespace::<DeepSeekV4ToolParser>(
rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/tests.rs
+22
@@ -71,6 +71,28 @@ fn spaced_dsml_streaming_preserves_shared_schema_coercion() {
71 }
72 }
73
74 +#[test]
75 +fn spaced_dsml_keeps_parameter_without_string_attr() {
76 + let wire = concat!(
77 + "<|DSML| calls>\n",
78 + "<|DSML| invoke name=\"lookup\">\n",
79 + "<|DSML| parameter name=\"query\">value</|DSML| parameter>\n",
80 + "<|DSML| parameter name=\"limit\">2</|DSML| parameter>\n",
81 + "</|DSML| invoke>\n",
82 + "</|DSML| calls>",
83 + );
84 + let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
85 +
86 + let output = collect_stream(parser.as_mut(), &[wire]);
87 +
88 + assert!(output.normal_text().is_empty());
89 + assert_eq!(output.calls().len(), 1);
90 + assert_eq!(
91 + serde_json::from_str::<serde_json::Value>(&output.calls()[0].arguments).unwrap(),
92 + json!({ "query": "value", "limit": 2 })
93 + );
94 +}
95 +
96 #[test]
97 fn v4_tags_remain_text_in_v41_dialect() {
98 let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
rust/src/parser/src/tool/deepseek_dsml/mod.rs
+7 -4
@@ -2,7 +2,7 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
5 -use winnow::combinator::{alt, delimited, eof, repeat, seq, terminated};
5 +use winnow::combinator::{alt, delimited, eof, opt, preceded, repeat, seq, terminated};
6 use winnow::prelude::*;
7 use winnow::stream::Partial;
8 use winnow::token::{literal, rest, take_until};
@@ -293,13 +293,16 @@ fn parse_invoke_params(invoke_body: &str, tokens: DsmlTokens) -> ModalResult<Vec
293 }
294
295 /// Parse a DSML parameter block.
296 +///
297 +/// The `string` attribute is optional: the model sometimes omits it, and
298 +/// failing the whole invoke would leak the block into the text output. A
299 +/// missing or non-`"true"` value is treated like `string="false"`.
300 fn parse_parameter(input: &mut &str, tokens: DsmlTokens) -> ModalResult<DsmlParameter> {
301 seq! {DsmlParameter {
302 _: literal(tokens.parameter_start),
303 _: ws1,
304 name: name_attr.map(|name: &str| name.to_string()),
301 - _: ws1,
302 - is_string: string_attr.map(|value| value == "true"),
305 + is_string: opt(preceded(ws1, string_attr)).map(|value| value == Some("true")),
306 _: ws0,
307 _: ">",
308 value: take_until(0.., tokens.parameter_end).map(str::to_string),
@@ -315,7 +318,7 @@ fn name_attr<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
318
319 /// Parse a string attribute.
320 fn string_attr<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
318 - delimited("string=\"", alt(("true", "false")), "\"").parse_next(input)
321 + delimited("string=\"", take_until(0.., "\""), "\"").parse_next(input)
322 }
323
324 /// Parse a DSML name attribute.
tests/parser/engine/test_deepseek_v4.py
+70
@@ -119,6 +119,35 @@ class TestArgConverter:
119 result = json.loads(_dsml_arg_converter("", partial=False))
120 assert result == {}
121
122 + def _bare(self, name: str, value: str) -> str:
123 + return f'<|DSML|parameter name="{name}">{value}{_PARAM_CLOSE}'
124 +
125 + def test_missing_string_attribute_keeps_literal(self):
126 + """The model sometimes omits ``string``; the parameter must survive."""
127 + raw = self._bare("name", "alpha")
128 + assert json.loads(_dsml_arg_converter(raw, partial=False)) == {"name": "alpha"}
129 +
130 + def test_missing_string_attribute_parses_json(self):
131 + raw = self._bare("count", "7") + self._bare("opts", '{"k": [1]}')
132 + assert json.loads(_dsml_arg_converter(raw, partial=False)) == {
133 + "count": 7,
134 + "opts": {"k": [1]},
135 + }
136 +
137 + def test_missing_string_attribute_alongside_annotated(self):
138 + raw = self._bare("a", "x") + _param("b", "true", "42")
139 + assert json.loads(_dsml_arg_converter(raw, partial=False)) == {
140 + "a": "x",
141 + "b": "42",
142 + }
143 +
144 + def test_missing_string_attribute_partial_value(self):
145 + raw = _param("a", "true", "x") + '<|DSML|parameter name="n">12'
146 + assert json.loads(_dsml_arg_converter(raw, partial=True)) == {
147 + "a": "x",
148 + "n": 12,
149 + }
150 +
151 def test_invalid_json_fallback(self):
152 raw = self._raw(("data", "false", "[broken"))
153 result = json.loads(_dsml_arg_converter(raw, partial=False))
@@ -232,6 +261,47 @@ class TestImplicitParameterClose:
261 }
262
263
264 +class TestMissingStringAttribute:
265 + def test_non_streaming_keeps_parameter(self, mock_tokenizer, mock_request):
266 + text = (
267 + f"{DSML_TOOL_START}"
268 + f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
269 + f'<|DSML|parameter name="location">Paris{_PARAM_CLOSE}\n'
270 + f"{_param('date', 'true', 'tomorrow')}\n"
271 + f"{DSML_INVOKE_END}{DSML_TOOL_END}"
272 + )
273 +
274 + result = DeepSeekV4Parser(mock_tokenizer).extract_tool_calls(text, mock_request)
275 +
276 + assert result.tools_called is True
277 + assert json.loads(result.tool_calls[0].function.arguments) == {
278 + "location": "Paris",
279 + "date": "tomorrow",
280 + }
281 +
282 + def test_streaming_keeps_parameter(self, mock_tokenizer, mock_request):
283 + chunks = [
284 + DSML_TOOL_START,
285 + f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n",
286 + '<|DSML|parameter name="loc',
287 + 'ation">Par',
288 + "is",
289 + _PARAM_CLOSE,
290 + f"\n{_param('date', 'true', 'tomorrow')}\n",
291 + DSML_INVOKE_END,
292 + DSML_TOOL_END,
293 + ]
294 +
295 + results = simulate_tool_streaming(
296 + DeepSeekV4Parser(mock_tokenizer), mock_request, chunks
297 + )
298 +
299 + assert json.loads(collect_tool_arguments(results)) == {
300 + "location": "Paris",
301 + "date": "tomorrow",
302 + }
303 +
304 +
305 # ── Bare </think> absorption and duplicate <think> absorption ─────────
306
307
tests/parser/engine/test_deepseek_v41.py
+2
@@ -137,10 +137,12 @@ def test_python_argument_conversion_and_partial_values():
137 '<|DSML| parameter name="object" string="false">'
138 '{"a": [true, null]}</|DSML| parameter>'
139 '<|DSML| parameter name="bad" string="false">[broken</|DSML| parameter>'
140 + '<|DSML| parameter name="bare">7</|DSML| parameter>'
141 '<|DSML| parameter name="text" string="true"> a<b'
142 )
143 assert json.loads(converter(raw, True)) == {
144 "object": {"a": [True, None]},
145 "bad": "[broken",
146 + "bare": 7,
147 "text": " a<b",
148 }
vllm/parser/deepseek_v4.py
+20 -12
@@ -56,18 +56,24 @@ DSML_TOOL_START_VARIANTS: tuple[str, ...] = (
56 f"<{_DSML}tool>",
57 )
58
59 -_ESCAPED_DSML = re.escape(_DSML)
60 -_PARAM_RE = re.compile(
61 - rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
62 - rf"(.*?)"
63 - rf"(?:</{_ESCAPED_DSML}parameter>|(?=<{_ESCAPED_DSML}parameter\s+name=))",
64 - re.DOTALL,
65 -)
66 -_PARTIAL_PARAM_RE = re.compile(
67 - rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
68 - rf"(.*)$",
69 - re.DOTALL,
70 -)
59 +
60 +def _param_patterns(
61 + param_start: str, param_close: str
62 +) -> tuple[re.Pattern, re.Pattern]:
63 + """Complete and trailing-partial parameter regexes for one DSML dialect.
64 +
65 + The ``string`` attribute is optional: the model sometimes omits it, and
66 + dropping such a parameter hands the client a tool call with no arguments.
67 + """
68 + start, close = re.escape(param_start), re.escape(param_close)
69 + head = rf'{start}\s+name="([^"]+)"(?:\s+string="([^"]*)")?\s*>'
70 + return (
71 + re.compile(rf"{head}(.*?)(?:{close}|(?={start}\s+name=))", re.DOTALL),
72 + re.compile(rf"{head}(.*)$", re.DOTALL),
73 + )
74 +
75 +
76 +_PARAM_RE, _PARTIAL_PARAM_RE = _param_patterns(DSML_PARAM_START, DSML_PARAM_CLOSE)
77
78
79 def _dsml_arg_converter(
@@ -80,6 +86,8 @@ def _dsml_arg_converter(
86 params: dict[str, object] = {}
87
88 last_end = 0
89 + # A missing ``string`` attribute is treated like ``string="false"``: try
90 + # JSON, fall back to the literal text.
91 for m in param_re.finditer(raw_args):
92 name, is_str, value = m.group(1), m.group(2), m.group(3)
93 if is_str == "true":
vllm/parser/deepseek_v41.py
+2 -13
@@ -5,11 +5,10 @@
5 import functools
6 from dataclasses import replace
7
8 -import regex as re
9 -
8 from vllm.parser.deepseek_v4 import (
9 DeepSeekV4Parser,
10 _dsml_arg_converter,
11 + _param_patterns,
12 deepseek_v4_config,
13 )
14 from vllm.parser.engine.parser_engine_config import ParserEngineConfig
@@ -21,17 +20,7 @@ DSML_INVOKE_END = "</|DSML| invoke>"
20 DSML_PARAM_START = "<|DSML| parameter"
21 DSML_PARAM_CLOSE = "</|DSML| parameter>"
22
24 -_PARAM_RE = re.compile(
25 - r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">'
26 - r"(.*?)"
27 - r"(?:</|DSML| parameter>|(?=<|DSML| parameter\s+name=))",
28 - re.DOTALL,
29 -)
30 -_PARTIAL_PARAM_RE = re.compile(
31 - r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">'
32 - r"(.*)$",
33 - re.DOTALL,
34 -)
23 +_PARAM_RE, _PARTIAL_PARAM_RE = _param_patterns(DSML_PARAM_START, DSML_PARAM_CLOSE)
24
25
26 @functools.cache