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
60 changes: 60 additions & 0 deletions tests/parser/engine/test_deepseek_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""Tests for DeepSeek V4-specific parser engine semantics."""

import json
from unittest.mock import MagicMock

import pytest

Expand Down Expand Up @@ -1391,3 +1392,62 @@ 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 "")


# ── Tool-call closer as a sampling stop ───────────────────────────────


class TestToolCallStop:
"""The closing tool_calls tag must end the turn.

Without it the sampler keeps going after a complete tool-call block and
the model opens another one, repeatedly, until it hits max_tokens.
"""

def _engine(self, mock_tokenizer):
return DeepSeekV4Parser(mock_tokenizer)

def _request(self, stop=None, tools=True):
req = MagicMock()
req.tools = [{"type": "function"}] if tools else None
req.stop = stop
req.include_stop_str_in_output = False
return req

def test_exposes_the_block_closer(self, mock_tokenizer):
assert self._engine(mock_tokenizer).tool_call_stop == DSML_TOOL_END

def test_adjust_request_sets_the_stop(self, mock_tokenizer):
"""The unified parser is what serves deepseek_v4.

It is used directly as the Parser -- ``tool_parser_cls`` is None and
no ParserEngineToolAdapter is ever built -- so a hook that lives only
on the adapter never runs for this model.
"""
req = self._engine(mock_tokenizer).adjust_request(self._request([]))
assert req.stop == [DSML_TOOL_END]
assert req.include_stop_str_in_output is True

def test_closer_is_added_and_kept_in_output(self, mock_tokenizer):
req = self._engine(mock_tokenizer)._add_tool_call_stop(self._request([]))
assert req.stop == [DSML_TOOL_END]
# the parser still has to see the closer to finish the call
assert req.include_stop_str_in_output is True

def test_preserves_caller_supplied_stops(self, mock_tokenizer):
e = self._engine(mock_tokenizer)
assert e._add_tool_call_stop(self._request(["A"])).stop == ["A", DSML_TOOL_END]
assert e._add_tool_call_stop(self._request("A")).stop == ["A", DSML_TOOL_END]

def test_idempotent(self, mock_tokenizer):
req = self._engine(mock_tokenizer)._add_tool_call_stop(
self._request([DSML_TOOL_END])
)
assert req.stop == [DSML_TOOL_END]

def test_untouched_without_tools(self, mock_tokenizer):
req = self._engine(mock_tokenizer)._add_tool_call_stop(
self._request([], tools=False)
)
assert req.stop == []
assert req.include_stop_str_in_output is False
12 changes: 9 additions & 3 deletions tests/tool_parsers/test_structural_tag_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,10 +524,13 @@ def fake_get_model_structural_tag(*, reasoning: bool, **kwargs):
assert captured == [False]


def test_unified_parser_get_structural_tag_disables_reasoning(
@pytest.mark.parametrize("emits_reasoning_span", [False, True])
def test_unified_parser_get_structural_tag_reasoning_follows_reasoning_parser(
monkeypatch: pytest.MonkeyPatch,
sample_tools_strict: list[ChatCompletionToolsParam],
emits_reasoning_span: bool,
):
"""The tag's reasoning span must match the request's thinking mode."""
captured: list[bool] = []

def fake_get_model_structural_tag(*, reasoning: bool, **kwargs):
Expand All @@ -549,11 +552,14 @@ class TestParser(DelegatingParser):
tool_choice="auto",
)
parser = TestParser(MagicMock(), tools=sample_tools_strict)
parser.reasoning_parser = MagicMock(adjust_request=lambda request: request)
parser.reasoning_parser = MagicMock(
adjust_request=lambda request: request,
emits_reasoning_span=emits_reasoning_span,
)

parser.adjust_request(request)

assert captured == [False]
assert captured == [emits_reasoning_span]


def test_xgrammar_function_parameters_are_preserved(
Expand Down
11 changes: 10 additions & 1 deletion vllm/parser/abstract_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,9 +578,18 @@ def _apply_structural_tag(
if not need_tool_calling:
return request

# The two grammars are not interchangeable: with reasoning=False the
# free-text span carries excludes=['<think>', '</think>'], so a model
# whose prompt ends inside an open thinking block can never close it;
# with reasoning=True the grammar opens with a span terminated by
# '</think>', which a non-thinking request never emits and EOS stays
# masked. Ask the reasoning parser which shape this request needs.
structure_tag = self._tool_parser.get_structural_tag(
request,
reasoning=False,
reasoning=(
self._reasoning_parser is not None
and self._reasoning_parser.emits_reasoning_span
),
)
if structure_tag is None:
return request
Expand Down
4 changes: 4 additions & 0 deletions vllm/parser/engine/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ def _skip_tool_parsing(self) -> Iterator[None]:
finally:
self._parser_engine.skip_tool_parsing = saved

@property
def emits_reasoning_span(self) -> bool:
return self._parser_engine.emits_reasoning_span

def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
return self._parser_engine.is_reasoning_end(list(input_ids))

Expand Down
56 changes: 56 additions & 0 deletions vllm/parser/engine/parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,60 @@ def _reset(self, initial_state: ParserState | None = None) -> None:
self._content_has_nonws = False
self._prompt_streaming_prepared = False

@property
def tool_call_stop(self) -> str | None:
"""The literal that closes a tool-call block, if the format has one.

Formats whose tool payload is delimited (DeepSeek's
``</|DSML|tool_calls>``, Qwen's ``</tool_calls>``, ...) expose it
here so the request path can hand it to the sampler as a stop
string. Formats without a distinct closer return ``None``.
"""
return self.parser_engine_config.terminal_literal("TOOL_END")

def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
request.skip_special_tokens = False
return self._add_tool_call_stop(request)

def _add_tool_call_stop(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
"""Stop generating once a tool-call block is closed.

The parser treats the closing tag as a state transition back to
CONTENT and keeps consuming, but nothing tells the *sampler* to
stop. A model that has just closed a tool-call block is free to
open another one, and in agentic workloads (long transcripts, many
tools) it does -- repeatedly, until it hits ``max_tokens``.
Observed in production on DeepSeek-V4-Flash: 32k output tokens and
~400s spent re-emitting the same block shape, with
``finish_reason`` still reported as ``tool_use``.

A tool call is a turn boundary: the client has to run the tool and
send the result back, so nothing generated after the closer can be
used. Handing the closer to the sampler ends the turn where it
logically ends.
"""
closer = self.tool_call_stop
if not closer or not getattr(request, "tools", None):
return request
stop = getattr(request, "stop", None)
if stop is None:
request.stop = [closer]
elif isinstance(stop, str):
if stop == closer:
return request
request.stop = [stop, closer]
elif closer in stop:
return request
else:
stop.append(closer)
# The parser needs to *see* the closer to finish the tool call, and
# stop strings are stripped from the output by default.
if hasattr(request, "include_stop_str_in_output"):
request.include_stop_str_in_output = True
return request

def _preprocess_feed(
Expand Down Expand Up @@ -647,6 +697,12 @@ def find_reasoning_end_offset(self, token_ids: Sequence[int]) -> int | None:
return offset
return None

@property
def emits_reasoning_span(self) -> bool:
# initial_state is derived per request from chat_template_kwargs, so
# this tracks the thinking/no-thinking distinction of *this* request.
return self.parser_engine_config.initial_state == ParserState.REASONING

def is_reasoning_end(self, input_ids: list[int]) -> bool:
end_id = self._reasoning_end_token_id
start_id = self._reasoning_start_token_id
Expand Down
13 changes: 13 additions & 0 deletions vllm/reasoning/abs_reasoning_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ def has_engine_confirmed_reasoning_end(self) -> bool:
"""
return False

@property
def emits_reasoning_span(self) -> bool:
"""Whether this request's output opens with a reasoning span.

Consulted when building a structural tag: the grammars xgrammar
derives for the two cases are not interchangeable. A reasoning grammar
expects the output to close a thinking block, while a non-reasoning one
excludes the thinking markers outright. Parsers whose thinking mode is
request-dependent should override this; the default preserves the
historical assumption that no reasoning span is emitted.
"""
return False

@abstractmethod
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
"""
Expand Down
Loading