[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:
co-authored by
Claude Opus 5
Bugen Zhao
parent
dee37d8911
commit
39e33db7f3
@@ -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.
|
||||
|
||||
@@ -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 ─────────
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user