Files
vllm/tests/parser/engine/test_token_id_scanner.py
T
2026-06-15 11:59:05 +08:00

632 lines
22 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for TokenIDScanner, focusing on hold-back text recovery.
Uses gemma4_config for all end-to-end engine tests, covering
reasoning channels, tool calls, and combined flows."""
from unittest.mock import MagicMock
import pytest
from vllm.parser.engine.events import EventType
from vllm.parser.engine.token_id_scanner import (
PreLexedTerminal,
TextChunk,
TokenIDScanner,
)
CHANNEL_START = "<|channel>"
CHANNEL_END = "<channel|>"
CHANNEL_START_ID = 100
CHANNEL_END_ID = 101
REGULAR_TOKEN_ID = 200
TOOL_START = "<tool_call>"
TOOL_END = "</tool_call>"
TOOL_START_ID = 110
TOOL_END_ID = 111
@pytest.fixture
def tokenizer():
tok = MagicMock()
tok.get_vocab.return_value = {
CHANNEL_START: CHANNEL_START_ID,
CHANNEL_END: CHANNEL_END_ID,
}
tok.decode.side_effect = lambda ids: {
CHANNEL_START_ID: CHANNEL_START,
CHANNEL_END_ID: CHANNEL_END,
REGULAR_TOKEN_ID: "regular",
}.get(ids[0], f"<unk:{ids[0]}>")
return tok
@pytest.fixture
def scanner(tokenizer):
return TokenIDScanner(
token_id_to_terminal={
CHANNEL_START_ID: "THINK_START",
CHANNEL_END_ID: "THINK_END",
},
tokenizer=tokenizer,
)
class TestJoinDecodedTextReturnsStr:
"""_join_decoded_text now returns str unconditionally (was
str | None when an isinstance guard made a branch unreachable)."""
@pytest.fixture
def bare_scanner(self):
return TokenIDScanner({}, tokenizer=None, drop_token_ids=set())
def test_mixed_items(self, bare_scanner):
items = [
TextChunk("hello "),
PreLexedTerminal("TOOL_START", 42, "<tool_call>"),
TextChunk(" world"),
]
result = bare_scanner._join_decoded_text(items)
assert isinstance(result, str)
assert result == "hello <tool_call> world"
def test_empty_list(self, bare_scanner):
result = bare_scanner._join_decoded_text([])
assert isinstance(result, str)
assert result == ""
def test_only_text_chunks(self, bare_scanner):
result = bare_scanner._join_decoded_text([TextChunk("abc"), TextChunk("def")])
assert result == "abcdef"
class TestHoldbackTextRecovery:
def test_holdback_text_with_special_token_text_absent(self, scanner):
"""delta_text has hold-back text but the special token's text is
NOT in delta_text (held back by the detokenizer). Terminal is
deferred until the text arrives in a subsequent delta."""
result = scanner.scan(
delta_text="processed is appropriate.",
delta_token_ids=[CHANNEL_END_ID],
)
assert len(result) == 0
# Second scan: terminal text arrives (detokenizer flushes).
# Deferred terminal resolves with holdback text before it.
result2 = scanner.scan(
delta_text="<channel|>Understood.",
delta_token_ids=[20, 21],
)
pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)]
assert len(pre_lexed) == 1
assert pre_lexed[0].terminal == "THINK_END"
texts = [r.text for r in result2 if isinstance(r, TextChunk)]
combined = "".join(texts)
assert "processed is appropriate." in combined
assert "Understood." in combined
def test_holdback_text_with_special_token_text_present(self, scanner):
"""delta_text includes hold-back text AND the special token text."""
result = scanner.scan(
delta_text="holdback text<channel|>",
delta_token_ids=[CHANNEL_END_ID],
)
assert len(result) == 2
assert isinstance(result[0], TextChunk)
assert result[0].text == "holdback text"
assert isinstance(result[1], PreLexedTerminal)
assert result[1].terminal == "THINK_END"
def test_no_holdback_text(self, scanner):
"""delta_text is exactly the special token text — no hold-back."""
result = scanner.scan(
delta_text="<channel|>",
delta_token_ids=[CHANNEL_END_ID],
)
assert len(result) == 1
assert isinstance(result[0], PreLexedTerminal)
assert result[0].terminal == "THINK_END"
def test_empty_delta_text(self, scanner):
"""delta_text is empty — terminal deferred until text arrives."""
result = scanner.scan(
delta_text="",
delta_token_ids=[CHANNEL_END_ID],
)
assert len(result) == 0
flushed = scanner.flush_pending()
assert len(flushed) == 1
assert isinstance(flushed[0], PreLexedTerminal)
assert flushed[0].terminal == "THINK_END"
def test_empty_delta_text_drops_individual_decode_text(self, tokenizer):
"""delta_text="" with multiple tokens including special: all
results deferred — individually-decoded TextChunks are unreliable
and PreLexedTerminals wait for text confirmation."""
tool_start_id = 400
tok_a = 201
tok_b = 202
tokenizer.decode.side_effect = lambda ids: {
tool_start_id: "<|tool_call>",
tok_a: "call:",
tok_b: "get_weather",
}.get(ids[0], "?")
scanner = TokenIDScanner(
token_id_to_terminal={tool_start_id: "TOOL_START"},
tokenizer=tokenizer,
)
result = scanner.scan(
delta_text="",
delta_token_ids=[tool_start_id, tok_a, tok_b],
)
assert len(result) == 0
flushed = scanner.flush_pending()
assert len(flushed) == 1
assert isinstance(flushed[0], PreLexedTerminal)
assert flushed[0].terminal == "TOOL_START"
def test_holdback_before_start_tag(self, scanner):
"""Hold-back text before a reasoning start tag."""
result = scanner.scan(
delta_text="prefix text<|channel>",
delta_token_ids=[CHANNEL_START_ID],
)
assert len(result) == 2
assert isinstance(result[0], TextChunk)
assert result[0].text == "prefix text"
assert isinstance(result[1], PreLexedTerminal)
assert result[1].terminal == "THINK_START"
def test_multi_token_batch_special_in_middle(self, scanner, tokenizer):
"""Stream-interval > 1: batch has regular tokens + special token.
delta_text differs from individual decodes (context-dependent)."""
tok_a = 201
tok_b = 202
tokenizer.decode.side_effect = lambda ids: {
tok_a: "wordA",
tok_b: "wordB",
CHANNEL_END_ID: CHANNEL_END,
}.get(ids[0], "?")
scanner_multi = TokenIDScanner(
token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
tokenizer=tokenizer,
)
result = scanner_multi.scan(
delta_text="holdback wordA<channel|> wordB",
delta_token_ids=[tok_a, CHANNEL_END_ID, tok_b],
)
texts = [r.text for r in result if isinstance(r, TextChunk)]
terminals = [r.terminal for r in result if isinstance(r, PreLexedTerminal)]
assert "THINK_END" in terminals
assert "holdback wordA" in "".join(texts)
def test_multi_token_batch_special_token_text_absent(self, scanner, tokenizer):
"""Stream-interval > 1: batch has regular + special token, but
delta_text doesn't contain the special token text at all
(held back by detokenizer along with trailing regular tokens).
Terminal is deferred until text arrives."""
tok_a = 201
tok_b = 202
tokenizer.decode.side_effect = lambda ids: {
tok_a: "alpha",
tok_b: "beta",
CHANNEL_END_ID: CHANNEL_END,
}.get(ids[0], "?")
scanner_multi = TokenIDScanner(
token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
tokenizer=tokenizer,
)
result = scanner_multi.scan(
delta_text="holdback alpha",
delta_token_ids=[tok_a, CHANNEL_END_ID, tok_b],
)
assert len(result) == 0
# Next delta: terminal text arrives (detokenizer flushes).
# Deferred terminal resolves with holdback text before it.
result2 = scanner_multi.scan(
delta_text="<channel|> more text",
delta_token_ids=[300],
)
pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)]
assert len(pre_lexed) == 1
assert pre_lexed[0].terminal == "THINK_END"
text_chunks = [r for r in result2 if isinstance(r, TextChunk)]
combined = "".join(t.text for t in text_chunks)
assert "holdback alpha" in combined
assert "more text" in combined
def test_holdback_with_content_after_special_token(self, tokenizer):
"""delta_text has hold-back + special token + content after,
with corresponding token IDs for all parts."""
tok_content = 210
tokenizer.decode.side_effect = lambda ids: {
CHANNEL_END_ID: CHANNEL_END,
tok_content: "content start",
}.get(ids[0], "?")
scanner = TokenIDScanner(
token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
tokenizer=tokenizer,
)
result = scanner.scan(
delta_text="reasoning end.<channel|>content start",
delta_token_ids=[CHANNEL_END_ID, tok_content],
)
pre_lexed = [r for r in result if isinstance(r, PreLexedTerminal)]
assert len(pre_lexed) == 1
assert pre_lexed[0].terminal == "THINK_END"
text_chunks = [r for r in result if isinstance(r, TextChunk)]
combined = "".join(t.text for t in text_chunks)
assert "reasoning end." in combined
class TestDropTokens:
def test_drop_token_with_holdback(self, tokenizer):
"""Drop tokens stripped from delta_text, hold-back text preserved.
Terminal is deferred when its text is absent from delta_text."""
drop_id = 300
tokenizer.decode.side_effect = lambda ids: {
CHANNEL_END_ID: CHANNEL_END,
drop_id: "<eos>",
}.get(ids[0], "?")
scanner = TokenIDScanner(
token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
tokenizer=tokenizer,
drop_token_ids={drop_id},
)
result = scanner.scan(
delta_text="holdback<eos>",
delta_token_ids=[drop_id, CHANNEL_END_ID],
)
assert len(result) == 0
# Terminal text arrives in next delta; deferred terminal resolves.
result2 = scanner.scan(
delta_text="<channel|>content",
delta_token_ids=[20],
)
pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)]
assert len(pre_lexed) == 1
assert pre_lexed[0].terminal == "THINK_END"
texts = [r.text for r in result2 if isinstance(r, TextChunk)]
combined = "".join(texts)
assert "holdback" in combined
assert "<eos>" not in combined
assert len(scanner.flush_pending()) == 0
class TestEndToEndReasoningHoldback:
"""End-to-end tests through the full parser engine simulating
stream-interval > 1 and detokenizer hold-back, using
gemma4_config."""
def test_reasoning_content_not_truncated(self):
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
config = ParserEngineConfig(
name="test_channel",
initial_state=ParserState.CONTENT,
terminals={
"THINK_START": CHANNEL_START,
"THINK_END": CHANNEL_END,
},
token_id_terminals={
"THINK_START": CHANNEL_START,
"THINK_END": CHANNEL_END,
},
transitions={
(ParserState.CONTENT, "THINK_START"): Transition(
ParserState.REASONING,
(EventType.REASONING_START,),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
},
)
tok = MagicMock()
vocab = {
CHANNEL_START: CHANNEL_START_ID,
CHANNEL_END: CHANNEL_END_ID,
}
tok.get_vocab.return_value = vocab
tok.decode.side_effect = lambda ids: {
CHANNEL_START_ID: CHANNEL_START,
CHANNEL_END_ID: CHANNEL_END,
}.get(ids[0], f"tok{ids[0]}")
engine = StreamingParserEngine(config, tok)
all_events = []
# Delta 1: channel start token (text includes start tag)
all_events.extend(engine.feed(CHANNEL_START, [CHANNEL_START_ID]))
# Delta 2: reasoning text (normal content, no special tokens)
all_events.extend(
engine.feed(
"thought\nThe request was received and ",
[10, 11, 12, 13, 14],
)
)
# Delta 3: MORE reasoning text, the detokenizer held some back.
# Then channel end token arrives in token_ids, but its text
# is NOT in delta_text (held back by detokenizer).
# delta_text = previously held-back reasoning text only.
all_events.extend(
engine.feed(
"processed is appropriate.",
[CHANNEL_END_ID],
)
)
# Delta 4: detokenizer flushes held-back channel end text
# plus new content tokens.
all_events.extend(
engine.feed(
"<channel|>Understood.",
[20, 21],
)
)
all_events.extend(engine.finish())
reasoning_text = "".join(
e.value for e in all_events if e.type == EventType.REASONING_CHUNK
)
content_text = "".join(
e.value for e in all_events if e.type == EventType.TEXT_CHUNK
)
assert "processed is appropriate." in reasoning_text
assert "Understood." in content_text
def test_backtick_content_not_truncated(self):
"""Reproduces the hostname backtick truncation case."""
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
config = ParserEngineConfig(
name="test_channel",
initial_state=ParserState.CONTENT,
terminals={
"THINK_START": CHANNEL_START,
"THINK_END": CHANNEL_END,
},
token_id_terminals={
"THINK_START": CHANNEL_START,
"THINK_END": CHANNEL_END,
},
transitions={
(ParserState.CONTENT, "THINK_START"): Transition(
ParserState.REASONING,
(EventType.REASONING_START,),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
},
)
tok = MagicMock()
vocab = {
CHANNEL_START: CHANNEL_START_ID,
CHANNEL_END: CHANNEL_END_ID,
}
tok.get_vocab.return_value = vocab
tok.decode.side_effect = lambda ids: {
CHANNEL_START_ID: CHANNEL_START,
CHANNEL_END_ID: CHANNEL_END,
}.get(ids[0], f"tok{ids[0]}")
engine = StreamingParserEngine(config, tok)
all_events = []
all_events.extend(engine.feed(CHANNEL_START, [CHANNEL_START_ID]))
all_events.extend(
engine.feed(
"thought\n1/10 completed. Next: ",
[10, 11, 12, 13],
)
)
# Hold-back text includes backtick content; channel end text
# absent from delta_text.
all_events.extend(
engine.feed(
"`hostname`.\n",
[CHANNEL_END_ID],
)
)
# Next delta flushes channel end + tool call start
all_events.extend(
engine.feed(
"<channel|>tool output",
[20, 21],
)
)
all_events.extend(engine.finish())
reasoning_text = "".join(
e.value for e in all_events if e.type == EventType.REASONING_CHUNK
)
assert "`hostname`." in reasoning_text
class TestRebuildFromAnchorsLiteralLookalike:
"""When delta_text contains a literal mention of a special token's
text before the real special token, _rebuild_from_anchors must
anchor at the real occurrence, not the literal one."""
@pytest.fixture
def tool_scanner(self):
tok = MagicMock()
tok.get_vocab.return_value = {
TOOL_START: TOOL_START_ID,
TOOL_END: TOOL_END_ID,
}
tok.decode.side_effect = lambda ids: {
TOOL_START_ID: TOOL_START,
TOOL_END_ID: TOOL_END,
}.get(ids[0], f"t{ids[0]}")
return TokenIDScanner(
{TOOL_START_ID: "TOOL_START", TOOL_END_ID: "TOOL_END"},
tok,
)
def test_literal_before_real_anchor(self, tool_scanner):
"""Literal <tool_call> in prose followed by a real <tool_call>
special token — the scanner must split at the real one."""
delta_text = 'Use <tool_call> like this: <tool_call>{"name":"f"}</tool_call>'
delta_token_ids = [1, 2, 3, 4, 5, TOOL_START_ID, 6, 7, TOOL_END_ID]
items = tool_scanner.scan(delta_text, delta_token_ids)
text_parts = [it.text for it in items if isinstance(it, TextChunk)]
terminals = [it for it in items if isinstance(it, PreLexedTerminal)]
assert len(terminals) == 2
assert terminals[0].terminal == "TOOL_START"
assert terminals[1].terminal == "TOOL_END"
# The literal mention must appear in a text chunk, not be
# consumed by the TOOL_START anchor.
joined_text = "".join(text_parts)
assert "<tool_call>" in joined_text
assert '{"name":"f"}' in joined_text
def test_multiple_tool_calls_with_literal_between(self, tool_scanner):
"""Two real tool calls with a literal mention between them."""
delta_text = (
'<tool_call>{"name":"a"}</tool_call>'
" see <tool_call> syntax "
'<tool_call>{"name":"b"}</tool_call>'
)
delta_token_ids = [
TOOL_START_ID,
1,
TOOL_END_ID,
2,
3,
4,
TOOL_START_ID,
5,
TOOL_END_ID,
]
items = tool_scanner.scan(delta_text, delta_token_ids)
terminals = [it for it in items if isinstance(it, PreLexedTerminal)]
assert len(terminals) == 4
text_parts = [it.text for it in items if isinstance(it, TextChunk)]
joined_text = "".join(text_parts)
# The literal mention between the two real calls must be in text
assert "<tool_call> syntax" in joined_text
class TestRebuildFromAnchorsCascadingDeferral:
"""When a middle anchor's text is absent from delta_text,
only that anchor should be deferred — not subsequent ones
with valid positions."""
@pytest.fixture
def bare_scanner(self):
tok = MagicMock()
tok.decode.side_effect = lambda ids: f"t{ids[0]}"
return TokenIDScanner({}, tok)
def test_middle_anchor_missing_does_not_cascade(self, bare_scanner):
a = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START)
b = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END)
c = PreLexedTerminal("TOOL_END", TOOL_END_ID, TOOL_END)
delta_text = f"prefix{TOOL_START}middle{TOOL_END}suffix"
results = [a, b, c]
rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results)
terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)]
texts = [r for r in rebuilt if isinstance(r, TextChunk)]
joined = "".join(t.text for t in texts)
assert len(terminals) == 2
assert terminals[0].terminal == "TOOL_START"
assert terminals[1].terminal == "TOOL_END"
assert "prefix" in joined
assert "middle" in joined
assert "suffix" in joined
assert len(bare_scanner._deferred_terminals) == 1
assert bare_scanner._deferred_terminals[0].terminal == "THINK_END"
assert bare_scanner._deferred_post_text == ""
def test_first_anchor_missing_rest_still_emitted(self, bare_scanner):
a = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END)
b = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START)
delta_text = f"text{TOOL_START}more"
results = [a, b]
rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results)
terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)]
assert len(terminals) == 1
assert terminals[0].terminal == "TOOL_START"
assert len(bare_scanner._deferred_terminals) == 1
assert bare_scanner._deferred_terminals[0].terminal == "THINK_END"
def test_last_anchor_missing_preceding_still_emitted(self, bare_scanner):
a = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START)
b = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END)
delta_text = f"text{TOOL_START}more"
results = [a, b]
rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results)
terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)]
assert len(terminals) == 1
assert terminals[0].terminal == "TOOL_START"
texts = [r for r in rebuilt if isinstance(r, TextChunk)]
joined = "".join(t.text for t in texts)
assert "text" in joined
# "more" is deferred along with the missing terminal —
# it will be resolved in the next scan when the terminal
# text arrives.
assert bare_scanner._deferred_post_text == "more"
assert len(bare_scanner._deferred_terminals) == 1
assert bare_scanner._deferred_terminals[0].terminal == "THINK_END"