Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,31 @@ mod tests {
);
}

#[test]
fn deepseek_v32_parse_complete_misspelled_closer_does_not_swallow_next_parameter() {
// `</|DSML|>` in place of the closer costs nothing but itself: the
// value stops at the sigil and the next parameter survives.
let mut parser = DeepSeekV32ToolParser::new(&test_tools());
let output = parser
.parse_complete(
"<|DSML|function_calls>\n\
<|DSML|invoke name=\"get_weather\">\n\
<|DSML|parameter name=\"location\" string=\"true\">first</|DSML|>\n\
<|DSML|parameter name=\"city\" string=\"true\">Tokyo</|DSML|parameter\n\
<|DSML|parameter name=\"date\" string=\"true\">second</|DSML|parameter>\n\
</|DSML|invoke>\n\
</|DSML|function_calls>",
)
.unwrap();

assert_eq!(output.calls().len(), 1);
assert!(!output.calls()[0].arguments.contains("DSML"));
assert_eq!(
serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
json!({ "location": "first", "city": "Tokyo", "date": "second" })
);
}

#[test]
fn deepseek_v32_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
let mut parser = DeepSeekV32ToolParser::new(&test_tools());
Expand Down
26 changes: 26 additions & 0 deletions rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,32 @@ mod tests {
);
}

#[test]
fn deepseek_v4_streaming_misspelled_closer_split_across_chunks() {
let wire = concat!(
"<|DSML|tool_calls>\n<|DSML|invoke name=\"get_weather\">\n",
"<|DSML|parameter name=\"location\" string=\"true\">first</|DSML|>\n",
"<|DSML|parameter name=\"date\" string=\"true\">second</|DSML|parameter>\n",
"</|DSML|invoke>\n</|DSML|tool_calls>",
);
for split in wire.char_indices().map(|(i, _)| i).chain(std::iter::once(wire.len())) {
let mut parser = DeepSeekV4ToolParser::new(&test_tools());
let output = collect_stream(&mut parser, &[&wire[..split], &wire[split..]]);

assert!(output.normal_text().is_empty(), "split {split}");
assert_eq!(output.calls().len(), 1, "split {split}");
assert!(
!output.calls()[0].arguments.contains("DSML"),
"split {split}"
);
assert_eq!(
serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
json!({ "location": "first", "date": "second" }),
"split {split}"
);
}
}

#[test]
fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
assert_tool_framing_preserves_body_whitespace::<DeepSeekV4ToolParser>(
Expand Down
22 changes: 22 additions & 0 deletions rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ fn spaced_dsml_keeps_parameter_without_string_attr() {
);
}

#[test]
fn spaced_dsml_misspelled_closer_does_not_swallow_next_parameter() {
let wire = concat!(
"<|DSML| calls>\n",
"<|DSML| invoke name=\"lookup\">\n",
"<|DSML| parameter name=\"query\" string=\"true\">first</|DSML|>\n",
"<|DSML| parameter name=\"limit\" string=\"false\">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_eq!(output.calls().len(), 1);
assert!(!output.calls()[0].arguments.contains("DSML"));
assert_eq!(
serde_json::from_str::<serde_json::Value>(&output.calls()[0].arguments).unwrap(),
json!({ "query": "first", "limit": 2 })
);
}

#[test]
fn v4_tags_remain_text_in_v41_dialect() {
let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
Expand Down
35 changes: 31 additions & 4 deletions rust/src/parser/src/tool/deepseek_dsml/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project

use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
use winnow::combinator::{alt, delimited, eof, opt, preceded, repeat, seq, terminated};
use winnow::combinator::{alt, delimited, eof, opt, peek, preceded, repeat, seq, terminated};
use winnow::prelude::*;
use winnow::stream::Partial;
use winnow::token::{literal, rest, take_until};
use winnow::token::{literal, rest, take_until, take_while};

use super::parameters::ToolSchemas;
use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
Expand All @@ -22,6 +22,11 @@ pub use deepseek_v41::DeepSeekV41ToolParser;

type DsmlInput<'i> = Partial<&'i str>;

/// Prefix shared by every DSML opener, in every dialect.
const DSML_OPEN: &str = "<|DSML|";
/// Prefix shared by every DSML closer, in every dialect.
const DSML_CLOSE: &str = "</|DSML|";

#[derive(Debug, Clone, Copy)]
struct DsmlTokens {
tool_calls_start: &'static str,
Expand Down Expand Up @@ -297,6 +302,13 @@ fn parse_invoke_params(invoke_body: &str, tokens: DsmlTokens) -> ModalResult<Vec
/// 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"`.
///
/// The value ends at the first DSML tag. The sigil is rendered from special
/// tokens the model cannot emit as content, so whatever `</|DSML|...>`
/// follows the value is its closer, well-formed or not (`</|DSML|>` is seen
/// in production); reading on to the next well-formed closer would swallow
/// every parameter in between. A `<|DSML|...` opener closes the value
/// implicitly and is left for the next parameter.
fn parse_parameter(input: &mut &str, tokens: DsmlTokens) -> ModalResult<DsmlParameter> {
seq! {DsmlParameter {
_: literal(tokens.parameter_start),
Expand All @@ -305,12 +317,27 @@ fn parse_parameter(input: &mut &str, tokens: DsmlTokens) -> ModalResult<DsmlPara
is_string: opt(preceded(ws1, string_attr)).map(|value| value == Some("true")),
_: ws0,
_: ">",
value: take_until(0.., tokens.parameter_end).map(str::to_string),
_: literal(tokens.parameter_end),
value: take_until(0.., (DSML_CLOSE, DSML_OPEN)).map(str::to_string),
_: parameter_end,
}}
.parse_next(input)
}

/// Parse the end of a DSML parameter value: any `</|DSML|...>` closer, or an
/// implicit close before the next opener.
fn parameter_end(input: &mut &str) -> ModalResult<()> {
alt((
(
DSML_CLOSE,
take_while(0.., |c| c != '<' && c != '>'),
opt('>'),
)
.void(),
peek(DSML_OPEN).void(),
))
.parse_next(input)
}

/// Parse a name attribute.
fn name_attr<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
delimited("name=\"", take_until(1.., "\""), "\"").parse_next(input)
Expand Down
81 changes: 76 additions & 5 deletions tests/parser/engine/test_deepseek_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,17 +192,48 @@ def test_next_parameter_prefix_implicitly_closes_previous(self):
result = json.loads(_dsml_arg_converter(raw, partial=False))
assert result == {"city": "Tokyo\n"}

def test_implicit_close_preserves_malformed_text(self):
def test_malformed_dsml_closer_ends_value(self):
"""The sigil cannot be argument content, so a broken closer is still
the closer: it must not stay in the value."""
raw = (
f"<{_PARAM_OPEN.format(name='city', is_str='true')}"
"Tokyo</|DSML|parameter\n"
f"<{_PARAM_OPEN.format(name='unit', is_str='true')}celsius{_PARAM_CLOSE}"
)
result = json.loads(_dsml_arg_converter(raw, partial=False))
assert result == {
"city": "Tokyo</|DSML|parameter\n",
"unit": "celsius",
}
assert result == {"city": "Tokyo", "unit": "celsius"}

def test_misspelled_closer_does_not_swallow_next_parameter(self):
"""Production shape: `</|DSML|>` closes alpha; beta must survive intact
instead of being absorbed into alpha's value up to its real closer."""
raw = (
f"<{_PARAM_OPEN.format(name='alpha', is_str='true')}first</|DSML|>\n"
f"{_param('beta', 'true', 'second')}"
)
result = json.loads(_dsml_arg_converter(raw, partial=False))
assert result == {"alpha": "first", "beta": "second"}

def test_misspelled_closer_on_last_parameter(self):
raw = f"<{_PARAM_OPEN.format(name='n', is_str='false')}42</|DSML|>\n"
assert json.loads(_dsml_arg_converter(raw, partial=False)) == {"n": 42}

def test_closer_without_sigil_stays_in_value(self):
"""Without the sigil the text could be content, so it is kept; the
next parameter is still recovered by the implicit close."""
raw = (
f"<{_PARAM_OPEN.format(name='alpha', is_str='true')}first</+>\n"
f"{_param('beta', 'true', 'second')}"
)
result = json.loads(_dsml_arg_converter(raw, partial=False))
assert result == {"alpha": "first</+>\n", "beta": "second"}

@pytest.mark.parametrize(
"tail", ["</|DSML|", "</|DSML|>", "</|DSML|>\n<|DSML|"]
)
def test_partial_value_stops_at_dsml_tag(self, tail: str):
"""Streaming must never emit the sigil as part of an argument."""
raw = f"<{_PARAM_OPEN.format(name='alpha', is_str='true')}first{tail}"
assert json.loads(_dsml_arg_converter(raw, partial=True)) == {"alpha": "first"}

def test_null_string_false(self):
raw = self._raw(("val", "false", "null"))
Expand Down Expand Up @@ -302,6 +333,46 @@ def test_streaming_keeps_parameter(self, mock_tokenizer, mock_request):
}


class TestMisspelledParameterCloser:
"""`</|DSML|>` in place of `</|DSML|parameter>` must cost nothing but the
closer itself: both arguments arrive, and no DSML markup reaches the
client, in non-streaming and streaming mode alike."""

_BODY = (
f"{DSML_INVOKE_PREFIX}record_item{DSML_INVOKE_NAME_END}\n"
f"<{_PARAM_OPEN.format(name='alpha', is_str='true')}first</|DSML|>\n"
f"{_param('beta', 'true', 'second')}\n"
f"{DSML_INVOKE_END}"
)

def test_non_streaming(self, mock_tokenizer, mock_request):
text = f"{DSML_TOOL_START}\n{self._BODY}\n{DSML_TOOL_END}"

result = DeepSeekV4Parser(mock_tokenizer).extract_tool_calls(text, mock_request)

assert [tc.function.name for tc in result.tool_calls] == ["record_item"]
arguments = result.tool_calls[0].function.arguments
assert "DSML" not in arguments
assert json.loads(arguments) == {"alpha": "first", "beta": "second"}

@pytest.mark.parametrize("chunk_size", [1, 4, None], ids=lambda c: f"chunk={c}")
def test_streaming(self, mock_tokenizer, mock_request, chunk_size):
text = f"{DSML_TOOL_START}\n{self._BODY}\n{DSML_TOOL_END}"
chunks = (
[text]
if chunk_size is None
else [text[i : i + chunk_size] for i in range(0, len(text), chunk_size)]
)

results = simulate_tool_streaming(
DeepSeekV4Parser(mock_tokenizer), mock_request, chunks
)

arguments = collect_tool_arguments(results)
assert "DSML" not in arguments
assert json.loads(arguments) == {"alpha": "first", "beta": "second"}


# ── Bare </think> absorption and duplicate <think> absorption ─────────


Expand Down
14 changes: 14 additions & 0 deletions tests/parser/engine/test_deepseek_v41.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,20 @@ def test_reasoning_adapter_controls_and_usage(
assert not parser.is_reasoning_end([51, 100, 50])


def test_python_misspelled_closer_does_not_swallow_next_parameter():
converter = deepseek_v41_config().arg_converter
raw = (
'<|DSML| parameter name="alpha" string="true">first</|DSML|>\n'
'<|DSML| parameter name="beta" string="true">second</|DSML| parameter>\n'
'<|DSML| parameter name="gamma" string="true">par'
)
assert json.loads(converter(raw, True)) == {
"alpha": "first",
"beta": "second",
"gamma": "par",
}


def test_python_argument_conversion_and_partial_values():
converter = deepseek_v41_config().arg_converter
raw = (
Expand Down
23 changes: 16 additions & 7 deletions vllm/parser/deepseek_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,33 @@
f"<{_DSML}tool>",
)

_ESCAPED_DSML = re.escape(_DSML)
# A parameter value ends at the first DSML tag, in every dialect. The sigil is
# rendered from special tokens the model cannot emit as content, so whatever
# `</|DSML|...>` follows the value is its closer, well-formed or not
# (`</|DSML|>` is the common production shape); running on to the next
# well-formed closer instead swallows every parameter in between. A
# `<|DSML|...` opener closes the value implicitly and is left for the next
# match.
_PARAM_VALUE = rf"((?:(?!</?{_ESCAPED_DSML}).)*)"
_PARAM_END = rf"(?:</{_ESCAPED_DSML}[^<>]*>?|(?=<{_ESCAPED_DSML}))"

def _param_patterns(
param_start: str, param_close: str
) -> tuple[re.Pattern, re.Pattern]:

def _param_patterns(param_start: 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)
start = re.escape(param_start)
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),
re.compile(rf"{head}{_PARAM_VALUE}{_PARAM_END}", re.DOTALL),
re.compile(rf"{head}{_PARAM_VALUE}", re.DOTALL),
)


_PARAM_RE, _PARTIAL_PARAM_RE = _param_patterns(DSML_PARAM_START, DSML_PARAM_CLOSE)
_PARAM_RE, _PARTIAL_PARAM_RE = _param_patterns(DSML_PARAM_START)


def _dsml_arg_converter(
Expand Down
2 changes: 1 addition & 1 deletion vllm/parser/deepseek_v41.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
DSML_PARAM_START = "<|DSML| parameter"
DSML_PARAM_CLOSE = "</|DSML| parameter>"

_PARAM_RE, _PARTIAL_PARAM_RE = _param_patterns(DSML_PARAM_START, DSML_PARAM_CLOSE)
_PARAM_RE, _PARTIAL_PARAM_RE = _param_patterns(DSML_PARAM_START)


@functools.cache
Expand Down
Loading