diff --git a/tests/v1/structured_output/test_reasoning_structured_output.py b/tests/v1/structured_output/test_reasoning_structured_output.py index a12b36b8a44..861e919c102 100644 --- a/tests/v1/structured_output/test_reasoning_structured_output.py +++ b/tests/v1/structured_output/test_reasoning_structured_output.py @@ -10,6 +10,7 @@ import pytest from vllm.config import ModelConfig, SchedulerConfig, VllmConfig from vllm.v1.request import Request from vllm.v1.structured_output import StructuredOutputManager +from vllm.v1.structured_output.backend_types import StructuredOutputOptions class MockReasoner: @@ -214,6 +215,32 @@ class TestReasoningStructuredOutput: ) assert result is False + def test_should_advance_reasoning_just_ended_with_spec_decode_structural_tag( + self, + manager_with_reasoner, + mock_request_with_structured_output, + ): + """When reasoning ends this step, advance immediately for structural + tags with speculative decoding.""" + structured_req = mock_request_with_structured_output.structured_output_request + structured_req.reasoning_ended = False + structured_req.structured_output_key = ( + StructuredOutputOptions.STRUCTURAL_TAG, + "{}", + ) + reasoner = MockReasoner(tokenizer=Mock()) + reasoner.is_reasoning_end_streaming.return_value = True + structured_req.reasoner = reasoner + + manager_with_reasoner.vllm_config.speculative_config = Mock() + + result = manager_with_reasoner.should_advance( + mock_request_with_structured_output + ) + + assert structured_req.reasoning_ended is True + assert result is True + def test_should_advance_reasoning_already_ended( self, manager_with_reasoner, diff --git a/vllm/v1/structured_output/__init__.py b/vllm/v1/structured_output/__init__.py index 3080fe30b0b..6a4fcbb629f 100644 --- a/vllm/v1/structured_output/__init__.py +++ b/vllm/v1/structured_output/__init__.py @@ -15,6 +15,7 @@ from vllm.v1.structured_output.backend_guidance import GuidanceBackend from vllm.v1.structured_output.backend_types import ( StructuredOutputBackend, StructuredOutputGrammar, + StructuredOutputOptions, ) from vllm.v1.structured_output.backend_xgrammar import XgrammarBackend @@ -350,10 +351,22 @@ class StructuredOutputManager: if reasoner.is_reasoning_end_streaming( all_token_ids, itertools.islice(all_token_ids, start, None) ): - # Reasoning just ended, so we shouldn't advance til - # next pass structured_req.reasoning_ended = True + # Reasoning just ended this step. Defer FSM advance until the next + # pass (see reasoning_ended check above) for JSON/regex/choice/grammar: + # advancing on the closing boundary token can accept tokens that still + # belong to the reasoning stream. Structural tags are the only safe + # same-step exception: they model phased output (e.g. thinking tag -> + # answer tag), and speculative decoding must run grammar.validate_tokens + # on draft tokens produced immediately after that transition. + if ( + self.vllm_config.speculative_config is not None + and structured_req.structured_output_key[0] + == StructuredOutputOptions.STRUCTURAL_TAG + ): + return True + return False def clear_backend(self) -> None: