diff --git a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs index 632eda600f..9bbe39367b 100644 --- a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs @@ -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\n\ + <|DSML|parameter name=\"flag\" >true\n\ + <|DSML|parameter name=\"payload\" string=\"true\">{\"nested\":true}\n\ + \n\ + ", + ) + .unwrap(); + + assert_eq!(output.calls().len(), 1); + assert_eq!( + serde_json::from_str::(&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()); diff --git a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs index b65df235ca..032cfbe5e2 100644 --- a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs @@ -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\n", + "<|DSML|parameter name=\"date\" string=\"true\">tomorrow\n", + "\n", + ], + ); + + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!( + serde_json::from_str::(&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::( diff --git a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/tests.rs b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/tests.rs index d40f82d533..5a6758f5c9 100644 --- a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/tests.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/tests.rs @@ -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\n", + "<|DSML| parameter name=\"limit\">2\n", + "\n", + "", + ); + 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::(&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(); diff --git a/rust/src/parser/src/tool/deepseek_dsml/mod.rs b/rust/src/parser/src/tool/deepseek_dsml/mod.rs index 01a3fbe282..9c7dd20331 100644 --- a/rust/src/parser/src/tool/deepseek_dsml/mod.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/mod.rs @@ -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 ModalResult { 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. diff --git a/tests/parser/engine/test_deepseek_v4.py b/tests/parser/engine/test_deepseek_v4.py index 7cbf9791a7..2cf3a8bb85 100644 --- a/tests/parser/engine/test_deepseek_v4.py +++ b/tests/parser/engine/test_deepseek_v4.py @@ -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 absorption and duplicate absorption ───────── diff --git a/tests/parser/engine/test_deepseek_v41.py b/tests/parser/engine/test_deepseek_v41.py index cc7d6e0aed..d99ca6b2a1 100644 --- a/tests/parser/engine/test_deepseek_v41.py +++ b/tests/parser/engine/test_deepseek_v41.py @@ -137,10 +137,12 @@ def test_python_argument_conversion_and_partial_values(): '<|DSML| parameter name="object" string="false">' '{"a": [true, null]}' '<|DSML| parameter name="bad" string="false">[broken' + '<|DSML| parameter name="bare">7' '<|DSML| parameter name="text" string="true"> a", ) -_ESCAPED_DSML = re.escape(_DSML) -_PARAM_RE = re.compile( - rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">' - rf"(.*?)" - rf"(?:|(?=<{_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": diff --git a/vllm/parser/deepseek_v41.py b/vllm/parser/deepseek_v41.py index 668bdd4a6b..8421600510 100644 --- a/vllm/parser/deepseek_v41.py +++ b/vllm/parser/deepseek_v41.py @@ -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_PARAM_START = "<|DSML| parameter" DSML_PARAM_CLOSE = "" -_PARAM_RE = re.compile( - r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">' - r"(.*?)" - r"(?:|(?=<|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