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
89 changes: 30 additions & 59 deletions tests/reasoning/test_gptoss_reasoning_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
42 changes: 29 additions & 13 deletions vllm/reasoning/gptoss_reasoning_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading