[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