diff --git a/tests/reasoning/test_gptoss_reasoning_parser.py b/tests/reasoning/test_gptoss_reasoning_parser.py index a6f815b6ae5c..04154101dc3d 100644 --- a/tests/reasoning/test_gptoss_reasoning_parser.py +++ b/tests/reasoning/test_gptoss_reasoning_parser.py @@ -282,70 +282,41 @@ def test_tag_format_consistency(self, reasoning_parser): assert tag["begin"].startswith("<|channel|>") -@pytest.mark.parametrize( - "output, is_reasoning_end", - [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES], -) -def test_gptoss_is_reasoning_end_streaming( - output, - is_reasoning_end, - gpt_oss_tokenizer, -): - """Streaming override must agree with is_reasoning_end for all cases.""" - tokens = gpt_oss_tokenizer.tokenize(output) - parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer) - output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens) - delta_ids = output_ids[-1:] if output_ids else [] - actual = parser.is_reasoning_end_streaming(output_ids, delta_ids) - assert is_reasoning_end == actual +def test_gptoss_is_reasoning_end_streaming_signature(gpt_oss_tokenizer): + parser = GptOssReasoningParser(gpt_oss_tokenizer) + assert parser.is_reasoning_end_streaming([], []) is False -@pytest.mark.parametrize( - "output, is_reasoning_end", - [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES], -) -def test_gptoss_is_reasoning_end_streaming_long_prefix( - output, - is_reasoning_end, - gpt_oss_tokenizer, -): - """Windowing must produce correct results even with a long prefix.""" - tokens = gpt_oss_tokenizer.tokenize(output) +def test_streaming_fires_on_end_after_analysis(gpt_oss_tokenizer): parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer) - output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens) - # Prepend 10k dummy reasoning tokens to simulate a long generation - long_prefix = [1] * 10_000 - padded_ids = long_prefix + list(output_ids) - delta_ids = output_ids[-1:] if output_ids else [] - actual = parser.is_reasoning_end_streaming(padded_ids, delta_ids) - assert is_reasoning_end == actual + input_ids = gpt_oss_tokenizer.encode( + "<|start|>assistant<|channel|>analysis<|message|>thinking", + add_special_tokens=False, + ) + delta_ids = [parser._analysis_close_token_id] + assert parser.is_reasoning_end_streaming(input_ids, delta_ids) is True -@pytest.mark.parametrize( - "output, is_reasoning_end", - [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES], -) -def test_gptoss_is_reasoning_end_streaming_large_delta( - output, - is_reasoning_end, - gpt_oss_tokenizer, -): - """Simulate speculative decoding where the entire test sequence arrives - as a single large delta appended after a long prefix. The window must - expand to cover delta_ids so the end pattern is never missed.""" - tokens = gpt_oss_tokenizer.tokenize(output) +def test_streaming_does_not_fire_on_tool_result_end(gpt_oss_tokenizer): parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer) - output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens) - long_prefix = [1] * 10_000 - padded_ids = long_prefix + list(output_ids) - # delta_ids = the entire test sequence (as if accepted in one spec step) - delta_ids = list(output_ids) - actual = parser.is_reasoning_end_streaming(padded_ids, delta_ids) - assert is_reasoning_end == actual + input_ids = gpt_oss_tokenizer.encode( + "<|start|>tool<|message|>result data", + add_special_tokens=False, + ) + delta_ids = [parser._analysis_close_token_id] + assert parser.is_reasoning_end_streaming(input_ids, delta_ids) is False -def test_gptoss_is_reasoning_end_streaming_signature(gpt_oss_tokenizer): - """Verify the method is callable with the expected signature.""" - parser = GptOssReasoningParser(gpt_oss_tokenizer) - result = parser.is_reasoning_end_streaming([], []) - assert result is False +def test_input_side_check_unchanged_from_upstream(gpt_oss_tokenizer): + parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer) + input_ids = gpt_oss_tokenizer.encode( + "<|start|>assistant<|channel|>final<|message|>partial response", + add_special_tokens=False, + ) + assert parser.is_reasoning_end(input_ids) is True + multi_turn = gpt_oss_tokenizer.encode( + "<|start|>assistant<|channel|>final<|message|>done<|return|>" + "<|start|>user<|message|>hi<|end|><|start|>assistant", + add_special_tokens=False, + ) + assert parser.is_reasoning_end(multi_turn) is False diff --git a/vllm/reasoning/gptoss_reasoning_parser.py b/vllm/reasoning/gptoss_reasoning_parser.py index 1ba933cca31e..bad2d93aa79e 100644 --- a/vllm/reasoning/gptoss_reasoning_parser.py +++ b/vllm/reasoning/gptoss_reasoning_parser.py @@ -82,6 +82,12 @@ def __init__(self, tokenizer: PreTrainedTokenizerBase, *args, **kwargs): # previous messages in multi-turn conversations. self.eom_token_id = self.vocab["<|end|>"] self.reasoning_max_num_between_tokens = 20 + # Streaming-gate state — see is_reasoning_end_streaming. + self._analysis_close_token_id = self.vocab["<|end|>"] + self._channel_token_id = self.vocab["<|channel|>"] + self._analysis_channel_name_token_ids = tuple( + self.model_tokenizer.encode("analysis", add_special_tokens=False) + ) def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: end_token_ids_prefix = self.reasoning_end_token_ids_prefix @@ -112,24 +118,34 @@ def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: return True return False + # Gate fires on analysis-close <|end|> in delta (before the next + # channel-selection token), not on <|channel|>final<|message|> in + # input_ids. See PR description for the structural_tag byte-imitation + # case this fixes. def is_reasoning_end_streaming( self, input_ids: Sequence[int], delta_ids: Iterable[int] ) -> bool: - # The pattern window covers the end-of-reasoning marker itself. - # We add len(delta_ids) so that under speculative decoding (where - # a single step can accept many tokens) the entire accepted chunk - # is always inside the scan region. + """Return True iff this delta closes the current message's analysis + channel with ``<|end|>``. Used to activate the structured-output + matcher before the next channel-selection token. + """ delta_ids = tuple(delta_ids) - pattern_len = ( - len(self.reasoning_end_token_ids_prefix) - + self.reasoning_max_num_between_tokens - + len(self.reasoning_end_token_ids_suffix) - ) - window = pattern_len + len(delta_ids) + if self._analysis_close_token_id not in delta_ids: + return False + return self._last_channel_was_analysis(input_ids) + + def _last_channel_was_analysis(self, input_ids: Sequence[int]) -> bool: + """Walk back through ``input_ids`` to the most recent ``<|channel|>`` + token; return True iff its name tokens are ``analysis``. + """ + analysis_name = self._analysis_channel_name_token_ids n = len(input_ids) - if n <= window: - return self.is_reasoning_end(input_ids) - return self.is_reasoning_end(input_ids[n - window :]) + for i in range(n - 1, -1, -1): + if input_ids[i] == self._channel_token_id: + start = i + 1 + end = start + len(analysis_name) + return end <= n and tuple(input_ids[start:end]) == analysis_name + return False def extract_content_ids(self, input_ids: list[int]) -> list[int]: _, content, _ = parse_chat_output(input_ids)