From 26fec6d183c3957ee4dc0022452c430e745dd20f Mon Sep 17 00:00:00 2001 From: yzong-rh Date: Wed, 9 Sep 2026 17:10:23 -0400 Subject: [PATCH] [Bugfix] Fix Step-3.5 reasoning parser for structured outputs (#53174) Signed-off-by: Yifan Zong --- .../test_step3p5_reasoning_parser.py | 43 +++++------ vllm/reasoning/step3p5_reasoning_parser.py | 74 ++----------------- 2 files changed, 26 insertions(+), 91 deletions(-) diff --git a/tests/reasoning/test_step3p5_reasoning_parser.py b/tests/reasoning/test_step3p5_reasoning_parser.py index 8f62e7a2cb..aeaf940213 100644 --- a/tests/reasoning/test_step3p5_reasoning_parser.py +++ b/tests/reasoning/test_step3p5_reasoning_parser.py @@ -25,12 +25,11 @@ SIMPLE_REASONING = { "content": "This is the rest", "is_reasoning_end": True, } -# need to get into parser again to remove newline after COMPLETE_REASONING = { "output": "This is a reasoning section", "reasoning": "This is a reasoning section", "content": None, - "is_reasoning_end": False, + "is_reasoning_end": True, } NO_CONTENT = { "output": "This is content", @@ -72,7 +71,7 @@ COMPLETE_REASONING_WITH_THINK = { "output": "This is a reasoning section", "reasoning": "This is a reasoning section", "content": None, - "is_reasoning_end": False, + "is_reasoning_end": True, } MULTIPLE_LINES_WITH_THINK = { "output": "This\nThatThis is the rest\nThat", @@ -113,21 +112,21 @@ EMPTY_STREAMING = { NEW_LINE = { "output": "\nThis is a reasoning section\nThis is the rest", "reasoning": "This is a reasoning section", - "content": "This is the rest", + "content": "\nThis is the rest", "is_reasoning_end": True, } NEW_LINE_STREAMING = { "output": "\nThis is a reasoning section\n\nThis is the rest", "reasoning": "\nThis is a reasoning section", - "content": "This is the rest", + "content": "\nThis is the rest", "is_reasoning_end": True, } NEW_LINE_STREAMING_COMPLEX_CONTENT = { "output": "\n This is a \n reasoning section\n\n\n\n\nThis is the rest", "reasoning": "\n This is a \n reasoning section\n\n", - "content": "\nThis is the rest", + "content": "\n\nThis is the rest", "is_reasoning_end": True, } @@ -301,9 +300,20 @@ def test_reasoning( # Test is_reasoning_end output_ids = step3p5_tokenizer.convert_tokens_to_ids(output) - if streaming: - is_reasoning_end = parser.is_reasoning_end(output_ids) - assert is_reasoning_end == param_dict["is_reasoning_end"] + is_reasoning_end = parser.is_reasoning_end(output_ids) + assert is_reasoning_end == param_dict["is_reasoning_end"] + + # Test is_reasoning_end_streaming + current_ids: list[int] = [] + for token_id in output_ids: + current_ids.append(token_id) + if token_id == parser.end_token_id: + assert parser.is_reasoning_end_streaming(current_ids, [token_id]) + assert parser.is_reasoning_end_streaming(output_ids, output_ids) + break + assert not parser.is_reasoning_end_streaming(current_ids, [token_id]) + else: + assert not parser.is_reasoning_end_streaming(output_ids, output_ids) # Test extract_content if param_dict["content"] is not None: @@ -324,18 +334,5 @@ def test_reasoning( ) assert content == expected_content_ids else: - content = parser.extract_content_ids(output) + content = parser.extract_content_ids(output_ids) assert content == [] - - -def test_step3p5_streaming_drops_leading_newline(step3p5_tokenizer): - parser_cls = ReasoningParserManager.get_reasoning_parser("step3p5") - parser = parser_cls(step3p5_tokenizer) - output = "calc\nAnswer" - tokens = step3p5_tokenizer.tokenize(output) - output_tokens = [ - step3p5_tokenizer.convert_tokens_to_string([token]) for token in tokens - ] - - _, content = run_reasoning_extraction(parser, output_tokens, streaming=True) - assert content == "Answer" diff --git a/vllm/reasoning/step3p5_reasoning_parser.py b/vllm/reasoning/step3p5_reasoning_parser.py index fed6f2edaa..cc7c227e6d 100644 --- a/vllm/reasoning/step3p5_reasoning_parser.py +++ b/vllm/reasoning/step3p5_reasoning_parser.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from collections.abc import Iterable, Sequence +from collections.abc import Sequence from typing import TYPE_CHECKING from vllm.entrypoints.generate.base.protocol import DeltaMessage @@ -18,9 +18,11 @@ class Step3p5ReasoningParser(BaseThinkingReasoningParser): Reasoning parser for Step3p5 model. Step3p5 uses the ... format, but it tends to emit an extra - newline immediately before and/or after the token. This parser trims: - - the newline right before - - the newline right after + newline immediately before and/or after the token. This parser + trims the newline before in extract_reasoning*. + + Reasoning end detection doesn't wait for an extra token after so + structured output can constrain the first post-think token. """ @property @@ -38,60 +40,6 @@ class Step3p5ReasoningParser(BaseThinkingReasoningParser): # whether it is immediately before . self._pending_reasoning_newline = False - # Tracks whether we've seen but are still waiting for one more - # token to confirm the end. - self._end_token_pending = False - - def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: - return self._is_reasoning_end_from_ids(input_ids) - - def is_reasoning_end_streaming( - self, input_ids: Sequence[int], delta_ids: Iterable[int] - ) -> bool: - # Only examine newly generated tokens; they may contain multiple ids. - return self._is_reasoning_end_from_ids(tuple(delta_ids)) - - def _is_reasoning_end_from_ids(self, input_ids: Sequence[int]) -> bool: - # Scan backwards to find the last special token, or . - last_special = None - last_idx = -1 - for i in range(len(input_ids) - 1, -1, -1): - token_id = input_ids[i] - if token_id == self.start_token_id: - last_special = "start" - last_idx = i - break - if token_id == self.end_token_id: - last_special = "end" - last_idx = i - break - - if last_special == "start": - # If we're already waiting for one token after , do not - # clear the pending state just because the prompt contains . - # Streaming deltas should not include for this model. - if self._end_token_pending: - return False - # A start token after any end token means reasoning is ongoing. - self._end_token_pending = False - return False - - if last_special == "end": - # Require at least one token after before ending. - if last_idx < len(input_ids) - 1: - self._end_token_pending = False - return True - self._end_token_pending = True - return False - - # No special tokens in this input. If we were waiting for one token - # after , any new token completes the end. - if self._end_token_pending and input_ids: - self._end_token_pending = False - return True - - return False - def extract_reasoning( self, model_output: str, @@ -100,8 +48,6 @@ class Step3p5ReasoningParser(BaseThinkingReasoningParser): reasoning, content = super().extract_reasoning(model_output, request) if reasoning is not None: reasoning = reasoning.removesuffix("\n") - if content is not None: - content = content.removeprefix("\n") return reasoning or None, content or None def extract_reasoning_streaming( @@ -113,14 +59,6 @@ class Step3p5ReasoningParser(BaseThinkingReasoningParser): current_token_ids: Sequence[int], delta_token_ids: Sequence[int], ) -> DeltaMessage | None: - # Drop the immediate newline that models often emit after . - if previous_text.endswith(self.end_token) and delta_text: - if delta_text == "\n": - return None - elif delta_text.startswith("\n"): - remaining = delta_text.removeprefix("\n") - return DeltaMessage(content=remaining) if remaining else None - ret = super().extract_reasoning_streaming( previous_text, current_text,