[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>
This commit is contained in:
lazymio
2026-09-18 05:11:24 +00:00
committed by GitHub
co-authored by Claude Opus 5 Bugen Zhao
parent dee37d8911
commit 39e33db7f3
8 changed files with 174 additions and 29 deletions
@@ -193,6 +193,34 @@ mod tests {
);
}
#[test]
fn deepseek_v32_parse_complete_keeps_parameter_without_string_attr() {
// The model sometimes omits `string="..."`; the parameter must still
// be kept and converted through the schema, like `string="false"`.
let mut parser = DeepSeekV32ToolParser::new(&test_tools());
let output = parser
.parse_complete(
"<|DSML|function_calls>\n\
<|DSML|invoke name=\"convert\">\n\
<|DSML|parameter name=\"whole\">5.0</|DSML|parameter>\n\
<|DSML|parameter name=\"flag\" >true</|DSML|parameter>\n\
<|DSML|parameter name=\"payload\" string=\"true\">{\"nested\":true}</|DSML|parameter>\n\
</|DSML|invoke>\n\
</|DSML|function_calls>",
)
.unwrap();
assert_eq!(output.calls().len(), 1);
assert_eq!(
serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
json!({
"whole": 5.0,
"flag": true,
"payload": "{\"nested\":true}",
})
);
}
#[test]
fn deepseek_v32_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
let mut parser = DeepSeekV32ToolParser::new(&test_tools());
@@ -147,6 +147,29 @@ mod tests {
);
}
#[test]
fn deepseek_v4_streaming_keeps_parameter_without_string_attr() {
let mut parser = DeepSeekV4ToolParser::new(&test_tools());
let output = collect_stream(
&mut parser,
&[
"<|DSML|tool_calls>\n<|DSML|invoke name=\"get_weather\">\n",
"<|DSML|parameter name=\"loc",
"ation\">Par",
"is</|DSML|parameter>\n",
"<|DSML|parameter name=\"date\" string=\"true\">tomorrow</|DSML|parameter>\n",
"</|DSML|invoke>\n</|DSML|tool_calls>",
],
);
assert!(output.normal_text().is_empty());
assert_eq!(output.calls().len(), 1);
assert_eq!(
serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
json!({ "location": "Paris", "date": "tomorrow" })
);
}
#[test]
fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
assert_tool_framing_preserves_body_whitespace::<DeepSeekV4ToolParser>(
@@ -71,6 +71,28 @@ fn spaced_dsml_streaming_preserves_shared_schema_coercion() {
}
}
#[test]
fn spaced_dsml_keeps_parameter_without_string_attr() {
let wire = concat!(
"<|DSML| calls>\n",
"<|DSML| invoke name=\"lookup\">\n",
"<|DSML| parameter name=\"query\">value</|DSML| parameter>\n",
"<|DSML| parameter name=\"limit\">2</|DSML| parameter>\n",
"</|DSML| invoke>\n",
"</|DSML| calls>",
);
let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
let output = collect_stream(parser.as_mut(), &[wire]);
assert!(output.normal_text().is_empty());
assert_eq!(output.calls().len(), 1);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&output.calls()[0].arguments).unwrap(),
json!({ "query": "value", "limit": 2 })
);
}
#[test]
fn v4_tags_remain_text_in_v41_dialect() {
let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
@@ -2,7 +2,7 @@
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
use winnow::combinator::{alt, delimited, eof, repeat, seq, terminated};
use winnow::combinator::{alt, delimited, eof, opt, preceded, repeat, seq, terminated};
use winnow::prelude::*;
use winnow::stream::Partial;
use winnow::token::{literal, rest, take_until};
@@ -293,13 +293,16 @@ fn parse_invoke_params(invoke_body: &str, tokens: DsmlTokens) -> ModalResult<Vec
}
/// Parse a DSML parameter block.
///
/// The `string` attribute is optional: the model sometimes omits it, and
/// failing the whole invoke would leak the block into the text output. A
/// missing or non-`"true"` value is treated like `string="false"`.
fn parse_parameter(input: &mut &str, tokens: DsmlTokens) -> ModalResult<DsmlParameter> {
seq! {DsmlParameter {
_: literal(tokens.parameter_start),
_: ws1,
name: name_attr.map(|name: &str| name.to_string()),
_: ws1,
is_string: string_attr.map(|value| value == "true"),
is_string: opt(preceded(ws1, string_attr)).map(|value| value == Some("true")),
_: ws0,
_: ">",
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> {
/// Parse a string attribute.
fn string_attr<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
delimited("string=\"", alt(("true", "false")), "\"").parse_next(input)
delimited("string=\"", take_until(0.., "\""), "\"").parse_next(input)
}
/// Parse a DSML name attribute.
+70
View File
@@ -119,6 +119,35 @@ class TestArgConverter:
result = json.loads(_dsml_arg_converter("", partial=False))
assert result == {}
def _bare(self, name: str, value: str) -> str:
return f'<|DSML|parameter name="{name}">{value}{_PARAM_CLOSE}'
def test_missing_string_attribute_keeps_literal(self):
"""The model sometimes omits ``string``; the parameter must survive."""
raw = self._bare("name", "alpha")
assert json.loads(_dsml_arg_converter(raw, partial=False)) == {"name": "alpha"}
def test_missing_string_attribute_parses_json(self):
raw = self._bare("count", "7") + self._bare("opts", '{"k": [1]}')
assert json.loads(_dsml_arg_converter(raw, partial=False)) == {
"count": 7,
"opts": {"k": [1]},
}
def test_missing_string_attribute_alongside_annotated(self):
raw = self._bare("a", "x") + _param("b", "true", "42")
assert json.loads(_dsml_arg_converter(raw, partial=False)) == {
"a": "x",
"b": "42",
}
def test_missing_string_attribute_partial_value(self):
raw = _param("a", "true", "x") + '<|DSML|parameter name="n">12'
assert json.loads(_dsml_arg_converter(raw, partial=True)) == {
"a": "x",
"n": 12,
}
def test_invalid_json_fallback(self):
raw = self._raw(("data", "false", "[broken"))
result = json.loads(_dsml_arg_converter(raw, partial=False))
@@ -232,6 +261,47 @@ class TestImplicitParameterClose:
}
class TestMissingStringAttribute:
def test_non_streaming_keeps_parameter(self, mock_tokenizer, mock_request):
text = (
f"{DSML_TOOL_START}"
f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
f'<|DSML|parameter name="location">Paris{_PARAM_CLOSE}\n'
f"{_param('date', 'true', 'tomorrow')}\n"
f"{DSML_INVOKE_END}{DSML_TOOL_END}"
)
result = DeepSeekV4Parser(mock_tokenizer).extract_tool_calls(text, mock_request)
assert result.tools_called is True
assert json.loads(result.tool_calls[0].function.arguments) == {
"location": "Paris",
"date": "tomorrow",
}
def test_streaming_keeps_parameter(self, mock_tokenizer, mock_request):
chunks = [
DSML_TOOL_START,
f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n",
'<|DSML|parameter name="loc',
'ation">Par',
"is",
_PARAM_CLOSE,
f"\n{_param('date', 'true', 'tomorrow')}\n",
DSML_INVOKE_END,
DSML_TOOL_END,
]
results = simulate_tool_streaming(
DeepSeekV4Parser(mock_tokenizer), mock_request, chunks
)
assert json.loads(collect_tool_arguments(results)) == {
"location": "Paris",
"date": "tomorrow",
}
# ── Bare </think> absorption and duplicate <think> absorption ─────────
+2
View File
@@ -137,10 +137,12 @@ def test_python_argument_conversion_and_partial_values():
'<|DSML| parameter name="object" string="false">'
'{"a": [true, null]}</|DSML| parameter>'
'<|DSML| parameter name="bad" string="false">[broken</|DSML| parameter>'
'<|DSML| parameter name="bare">7</|DSML| parameter>'
'<|DSML| parameter name="text" string="true"> a<b'
)
assert json.loads(converter(raw, True)) == {
"object": {"a": [True, None]},
"bad": "[broken",
"bare": 7,
"text": " a<b",
}
+20 -12
View File
@@ -56,18 +56,24 @@ DSML_TOOL_START_VARIANTS: tuple[str, ...] = (
f"<{_DSML}tool>",
)
_ESCAPED_DSML = re.escape(_DSML)
_PARAM_RE = re.compile(
rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
rf"(.*?)"
rf"(?:</{_ESCAPED_DSML}parameter>|(?=<{_ESCAPED_DSML}parameter\s+name=))",
re.DOTALL,
)
_PARTIAL_PARAM_RE = re.compile(
rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
rf"(.*)$",
re.DOTALL,
)
def _param_patterns(
param_start: str, param_close: str
) -> tuple[re.Pattern, re.Pattern]:
"""Complete and trailing-partial parameter regexes for one DSML dialect.
The ``string`` attribute is optional: the model sometimes omits it, and
dropping such a parameter hands the client a tool call with no arguments.
"""
start, close = re.escape(param_start), re.escape(param_close)
head = rf'{start}\s+name="([^"]+)"(?:\s+string="([^"]*)")?\s*>'
return (
re.compile(rf"{head}(.*?)(?:{close}|(?={start}\s+name=))", re.DOTALL),
re.compile(rf"{head}(.*)$", re.DOTALL),
)
_PARAM_RE, _PARTIAL_PARAM_RE = _param_patterns(DSML_PARAM_START, DSML_PARAM_CLOSE)
def _dsml_arg_converter(
@@ -80,6 +86,8 @@ def _dsml_arg_converter(
params: dict[str, object] = {}
last_end = 0
# A missing ``string`` attribute is treated like ``string="false"``: try
# JSON, fall back to the literal text.
for m in param_re.finditer(raw_args):
name, is_str, value = m.group(1), m.group(2), m.group(3)
if is_str == "true":
+2 -13
View File
@@ -5,11 +5,10 @@
import functools
from dataclasses import replace
import regex as re
from vllm.parser.deepseek_v4 import (
DeepSeekV4Parser,
_dsml_arg_converter,
_param_patterns,
deepseek_v4_config,
)
from vllm.parser.engine.parser_engine_config import ParserEngineConfig
@@ -21,17 +20,7 @@ DSML_INVOKE_END = "</|DSML| invoke>"
DSML_PARAM_START = "<|DSML| parameter"
DSML_PARAM_CLOSE = "</|DSML| parameter>"
_PARAM_RE = re.compile(
r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">'
r"(.*?)"
r"(?:</|DSML| parameter>|(?=<|DSML| parameter\s+name=))",
re.DOTALL,
)
_PARTIAL_PARAM_RE = re.compile(
r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">'
r"(.*)$",
re.DOTALL,
)
_PARAM_RE, _PARTIAL_PARAM_RE = _param_patterns(DSML_PARAM_START, DSML_PARAM_CLOSE)
@functools.cache