Skip to content
Merged
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
4 changes: 2 additions & 2 deletions tests/parser/engine/test_deepseek_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,8 +643,8 @@ def test_reasoning_end_token_ids_drop_multi_token_tool_opener(
# token-ID fast path; the implicit tool-call exit still goes through
# the text lexer.
assert thinking_parser.reasoning_end_token_ids == {_THINK_END_ID}
assert thinking_parser.find_reasoning_end_offset([7, _THINK_END_ID, 8]) == 1
assert thinking_parser.find_reasoning_end_offset([7, 8]) == 2
assert thinking_parser.find_reasoning_end([7, _THINK_END_ID, 8]) == (1, False)
assert thinking_parser.find_reasoning_end([7, 8]) == (2, False)

def test_streaming_reasoning_implicit_end(self, thinking_parser):
chunks = [
Expand Down
45 changes: 36 additions & 9 deletions tests/parser/engine/test_parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@
from vllm.parser.abstract_parser import DelegatingParser
from vllm.parser.engine.adapters import make_adapters
from vllm.parser.engine.events import EventType, SemanticEvent
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine import ParserEngine, ReasoningEnd
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
from vllm.parser.glm47_moe import glm47_moe_config
from vllm.parser.parser_manager import ParserManager

# ── Shared test configs ──────────────────────────────────────────────
Expand Down Expand Up @@ -158,7 +159,10 @@ def test_every_reasoning_end_exit_contributes(self):
(EventType.REASONING_END, EventType.TOOL_CALL_START),
),
)
assert _make_engine(cfg).reasoning_end_token_ids == {201, 202}
engine = _make_engine(cfg)
assert engine.reasoning_end_token_ids == {201, 202}
assert engine.find_reasoning_end([5, 201]) == ReasoningEnd(1, False)
assert engine.find_reasoning_end([5, 202]) == ReasoningEnd(1, True)

def test_transition_staying_in_reasoning_is_ignored(self):
cfg = _with_reasoning_exits(("THINK_START", ParserState.REASONING, ()))
Expand All @@ -182,17 +186,40 @@ def test_unresolved_think_end_fails_closed(self):
def test_config_without_reasoning_has_empty_set(self):
assert _make_engine(_hermes_config()).reasoning_end_token_ids == frozenset()

def test_find_reasoning_end_offset_returns_first_match(self):
def test_find_reasoning_end_returns_first_match(self):
engine = _make_engine()
assert engine.find_reasoning_end_offset([5, 201, 6, 201]) == 1
assert engine.find_reasoning_end_offset([5, 6]) == 2
assert engine.find_reasoning_end_offset([]) == 0
assert engine.find_reasoning_end([5, 201, 6, 201]) == ReasoningEnd(1, False)
assert engine.find_reasoning_end([5, 6]) == ReasoningEnd(2, False)
assert engine.find_reasoning_end([]) == ReasoningEnd(0, False)
# Rejected-draft placeholders never match.
assert engine.find_reasoning_end_offset([-1, 201]) == 1
assert engine.find_reasoning_end([-1, 201]) == ReasoningEnd(1, False)

def test_find_reasoning_end_offset_with_empty_set_returns_none(self):
def test_find_reasoning_end_with_empty_set_returns_none(self):
engine = _make_engine(_hermes_config())
assert engine.find_reasoning_end_offset([201]) is None
assert engine.find_reasoning_end([201]) is None


@pytest.mark.parametrize(
("config", "implicit_token"),
[
pytest.param(glm47_moe_config(), "<tool_call>", id="glm47_moe"),
],
)
def test_implicit_reasoning_end_of_parsers(
config: ParserEngineConfig, implicit_token: str
):
literals = sorted(set(config.token_id_terminals.values()))
vocab = {literal: 1000 + i for i, literal in enumerate(literals)}
engine = _make_engine(config, vocab=vocab)
implicit_ids = set()
for token_id in engine.reasoning_end_token_ids:
end = engine.find_reasoning_end([5, token_id, 6])
assert end is not None and end.offset == 1
kept = engine.extract_content_ids([5, token_id, 6]) == [token_id, 6]
assert end.implicit == kept
if end.implicit:
implicit_ids.add(token_id)
assert implicit_ids == {vocab[implicit_token]}


# ── TestEventsToDelta ────────────────────────────────────────────────
Expand Down
7 changes: 4 additions & 3 deletions tests/v1/spec_decode/test_mtp_structured_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from vllm.config.model import ModelConfig
from vllm.config.speculative import SpeculativeConfig
from vllm.parser.engine.adapters import ParserEngineReasoningAdapter
from vllm.parser.engine.parser_engine import ReasoningEnd
from vllm.sampling_params import SamplingParams, StructuredOutputsParams
from vllm.v1.request import Request
from vllm.v1.structured_output import StructuredOutputManager
Expand Down Expand Up @@ -113,12 +114,12 @@ def __init__(self, end_token_ids):
def reasoning_end_token_ids(self):
return self._end_token_ids

def find_reasoning_end_offset(self, token_ids):
def find_reasoning_end(self, token_ids):
self.windows.append(list(token_ids))
for offset, token in enumerate(token_ids):
if token in self._end_token_ids:
return offset
return len(token_ids)
return ReasoningEnd(offset, False)
return ReasoningEnd(len(token_ids), False)

def is_reasoning_end(self, input_ids):
return any(token in self._end_token_ids for token in input_ids)
Expand Down
48 changes: 47 additions & 1 deletion tests/v1/structured_output/test_structured_output_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
from vllm.config.model import ModelConfig
from vllm.config.speculative import SpeculativeConfig
from vllm.config.structured_outputs import StructuredOutputsBackend
from vllm.parser.engine.adapters import ParserEngineReasoningAdapter
from vllm.parser.engine.parser_engine import ReasoningEnd
from vllm.sampling_params import SamplingParams, StructuredOutputsParams
from vllm.v1.request import Request
from vllm.v1.structured_output import StructuredOutputManager
from vllm.v1.structured_output.backend_outlines import OutlinesGrammar

TOKENIZER = "gpt2"
THINK_END = "\n" # reasoning-end marker (single GPT-2 token)
IMPLICIT_THINK_END = "z" # accepted in strings, rejected at the object start
EOS = "<|eos|>" # resolved to tokenizer.eos_token_id
JSON_SCHEMA = '{"type": "object"}'
BACKENDS = ("xgrammar", "guidance")
Expand All @@ -43,6 +46,7 @@ class FlowCase:
prefix: str = ""
expected_validated: tuple[str, ...] | None = None
reasoning_ended: bool | None = False
engine_reasoner: bool = False
xfail_guidance: str | None = None


Expand All @@ -59,6 +63,23 @@ def is_reasoning_end_streaming(self, input_ids, delta_ids):
return self.is_reasoning_end(delta_ids)


class MockEngineReasoner(ParserEngineReasoningAdapter):
"""Ends reasoning implicitly on ``marker``, which is also content."""

def __init__(self, tokenizer, marker: int):
self.marker = marker

@property
def reasoning_end_token_ids(self):
return frozenset({self.marker})

def find_reasoning_end(self, token_ids):
ids = list(token_ids)
if self.marker in ids:
return ReasoningEnd(ids.index(self.marker), True)
return ReasoningEnd(len(ids), False)


def _single_token(tokenizer, text: str) -> int:
token_ids = tokenizer.encode(text)
assert len(token_ids) == 1, (text, token_ids)
Expand Down Expand Up @@ -331,6 +352,28 @@ def _run_real_flow(
),
id="becomes_active_terminates",
),
pytest.param(
FlowCase(
prefix='{"a": "',
raw_drafts=(IMPLICIT_THINK_END,),
expected_row_pattern="UC",
expected_reasoning=True,
expect_terminated=False,
engine_reasoner=True,
),
id="implicit_end_accepted",
),
pytest.param(
FlowCase(
raw_drafts=(" ", IMPLICIT_THINK_END, "{", "}"),
expected_validated=(" ",),
expected_row_pattern="UUUUU",
expected_reasoning=False,
expect_terminated=False,
engine_reasoner=True,
),
id="implicit_end_rejected",
),
]


Expand All @@ -344,14 +387,17 @@ def test_real_flow(
if backend == "guidance" and case.xfail_guidance:
pytest.xfail(case.xfail_guidance)

reasoner_kwargs = {"marker": _single_token(tokenizer, THINK_END)}
marker = IMPLICIT_THINK_END if case.engine_reasoner else THINK_END
reasoner_kwargs = {"marker": _single_token(tokenizer, marker)}
manager, request = _build_harness(
tokenizer,
backend,
prefix=case.prefix,
reasoning_ended=case.reasoning_ended,
reasoning_parser_kwargs=reasoner_kwargs,
)
if case.engine_reasoner:
manager.reasoner_cls = MockEngineReasoner

raw_drafts = _to_token_ids(tokenizer, case.raw_drafts)
expected_texts = (
Expand Down
6 changes: 3 additions & 3 deletions vllm/parser/engine/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
ChatCompletionRequest,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine import ParserEngine, ReasoningEnd
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.utils import Tool

Expand Down Expand Up @@ -65,8 +65,8 @@ def _skip_tool_parsing(self) -> Iterator[None]:
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
return self._parser_engine.is_reasoning_end(list(input_ids))

def find_reasoning_end_offset(self, token_ids: Sequence[int]) -> int | None:
return self._parser_engine.find_reasoning_end_offset(token_ids)
def find_reasoning_end(self, token_ids: Sequence[int]) -> ReasoningEnd | None:
return self._parser_engine.find_reasoning_end(token_ids)

def adjust_initial_state_from_prompt(self, prompt_token_ids: Sequence[int]) -> None:
self._parser_engine.adjust_initial_state_from_prompt(prompt_token_ids)
Expand Down
16 changes: 12 additions & 4 deletions vllm/parser/engine/parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import json
from collections.abc import Sequence
from functools import cached_property
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, NamedTuple

import regex as re

Expand Down Expand Up @@ -45,6 +45,13 @@
logger = init_logger(__name__)


class ReasoningEnd(NamedTuple):
"""Where reasoning ends in a token sequence."""

offset: int # index of the first end token, or the sequence length
implicit: bool # the end token is also content, e.g. a tool-call start


class ToolCallSlot:
__slots__ = (
"id",
Expand Down Expand Up @@ -638,14 +645,15 @@ def _derive_reasoning_end_token_ids(
def reasoning_end_token_ids(self) -> frozenset[int]:
return self._reasoning_end_token_ids

def find_reasoning_end_offset(self, token_ids: Sequence[int]) -> int | None:
def find_reasoning_end(self, token_ids: Sequence[int]) -> ReasoningEnd | None:
"""First reasoning end in `token_ids`, and if its token is content."""
end_ids = self._reasoning_end_token_ids
if not end_ids:
return None
for offset, token_id in enumerate(token_ids):
if token_id in end_ids:
return offset
return len(token_ids)
return ReasoningEnd(offset, token_id != self._reasoning_end_token_id)
return ReasoningEnd(len(token_ids), False)

def is_reasoning_end(self, input_ids: list[int]) -> bool:
config = self.parser_engine_config
Expand Down
Loading
Loading