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
63 changes: 63 additions & 0 deletions tests/parser/engine/test_deepseek_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
DSML_INVOKE_END,
DSML_INVOKE_NAME_END,
DSML_INVOKE_PREFIX,
DSML_PARAM_CLOSE,
DSML_THINK_END,
DSML_THINK_START,
DSML_TOOL_END,
Expand Down Expand Up @@ -1391,3 +1392,65 @@ def test_delegating_parser_corrupted_wrapper(self, wrapper, chunk_size):
assert output.tool_calls[0]["name"] == "get_weather"
assert json.loads(output.tool_calls[0]["arguments"]) == {"city": "Seoul"}
assert "DSML" not in (output.content or "")


# ── Orphan DSML structural closers in plain content ──────────────────


class TestOrphanStructuralCloserAbsorption:
"""Long-context degeneration can emit an orphan structural closer while
no tool call is open (e.g. a trailing ``</|DSML|parameter>``). The tag must be
absorbed, not echoed into the client-visible content.
"""

_STRAYS = [DSML_PARAM_CLOSE, DSML_INVOKE_END, DSML_TOOL_END]

@pytest.mark.parametrize("stray", _STRAYS)
def test_not_leaked_into_content_non_streaming(
self, mock_tokenizer, mock_request, stray
):
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
result = parser.extract_tool_calls("All done.\n" + stray, mock_request)

assert not result.tools_called
assert result.content == "All done.\n"
assert "DSML" not in (result.content or "")

@pytest.mark.parametrize("stray", _STRAYS)
def test_not_leaked_into_content_streaming(
self, mock_tokenizer, mock_request, stray
):
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
results = simulate_tool_streaming(
parser, mock_request, list("All done.\n" + stray)
)

assert collect_content(results) == "All done.\n"
assert collect_function_name(results) is None

def test_normal_tool_call_unaffected(self, mock_tokenizer, mock_request):
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
text = (
"Running.\n"
+ DSML_TOOL_START
+ DSML_INVOKE_PREFIX
+ "get_weather"
+ DSML_INVOKE_NAME_END
+ "\n"
+ _param("city", "true", "NYC")
+ "\n"
+ DSML_INVOKE_END
+ DSML_TOOL_END
)
result = parser.extract_tool_calls(text, mock_request)

assert result.tools_called is True
assert result.tool_calls[0].function.name == "get_weather"
assert json.loads(result.tool_calls[0].function.arguments) == {"city": "NYC"}
assert result.content == "Running.\n"
34 changes: 33 additions & 1 deletion tests/parser/engine/test_deepseek_v41.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
collect_output,
replay_streaming,
)
from vllm.parser.deepseek_v41 import deepseek_v41_config
from vllm.parser.deepseek_v4 import DSML_THINK_END
from vllm.parser.deepseek_v41 import (
DSML_INVOKE_END,
DSML_PARAM_CLOSE,
DSML_TOOL_END,
deepseek_v41_config,
)
from vllm.parser.parser_manager import ParserManager

CALLS = (
Expand Down Expand Up @@ -144,3 +150,29 @@ def test_python_argument_conversion_and_partial_values():
"bad": "[broken",
"text": " a<b",
}


@pytest.mark.parametrize("thinking", [False, True])
@pytest.mark.parametrize(
"stray",
[DSML_PARAM_CLOSE, DSML_INVOKE_END, DSML_TOOL_END],
ids=["param_close", "invoke_end", "tool_end"],
)
def test_orphan_structural_closer_is_absorbed(thinking, stray):
"""A stray spaced-DSML closer in plain content must not reach the
client (long-context degeneration emits these with no open call)."""
text = ("Plan." + DSML_THINK_END if thinking else "") + "All done." + stray
tokenizer, tokens = tokenizer_for(text, False)
parser = parser_for(tokenizer, {"thinking": thinking})
output = collect_output(
replay_streaming(
parser,
tokens,
chunk_size=1,
finished_on_last=True,
tools=DUMMY_TOOLS,
prompt_token_ids=[50 if thinking else 51],
)
)
assert output.content == "All done."
assert "DSML" not in output.content
4 changes: 4 additions & 0 deletions vllm/parser/deepseek_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
},
arg_converter=_dsml_arg_converter,
arg_structural_chars=frozenset(">"),
# Orphan structural closers emitted by long-context degeneration are
# markup, never client-visible text; the engine drops them in a
# content state instead of echoing them (see _on_terminal).
absorb_terminals=frozenset({"PARAM_CLOSE", "INVOKE_END", "TOOL_END"}),
strip_content_whitespace_with_tools=False,
tool_args_json=False,
)
Expand Down
5 changes: 5 additions & 0 deletions vllm/parser/engine/parser_engine_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ class ParserEngineConfig:
# Special tokens exempt from auto-drop but not state-machine terminals.
preserve_tokens: frozenset[str] = field(default_factory=frozenset)

# Structural markup terminals that carry no text of their own. A stray one
# that reaches a content state with no matching transition is dropped
# instead of echoed back to the client (see ``_on_terminal``).
absorb_terminals: frozenset[str] = field(default_factory=frozenset)

# Special tokens delimiting conversation turns in the prompt only
# considers reasoning markers after the last boundary token.
turn_boundary_tokens: frozenset[str] = field(default_factory=frozenset)
Expand Down
12 changes: 12 additions & 0 deletions vllm/parser/engine/streaming_parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,18 @@ def _on_terminal(
if transition is None:
if self._has_drops and terminal == DROP_TERMINAL:
return []
# Long-context degeneration emits orphan structural closers
# (e.g. a bare ``</|DSML|parameter>``) while no tool call is open.
# Drop the tag instead of echoing the raw markup as text. Only
# the tool-owning pass absorbs; the skip-tool pass must pass the
# markup through so it still parses in the downstream tool
# adapter.
if (
terminal in self.config.absorb_terminals
and self.state in self._PLAIN_STATES
and not self.skip_tool_parsing
):
return []
# The projected skip state may not define the wrapper closer.
if self.skip_tool_parsing and terminal in self._tool_exit_terminals:
self._in_skipped_tool_span = False
Expand Down
Loading