[Bugfix][Parser] Fix special tokens (EOS/BOS) leaking into reasoning content (#48748)
Signed-off-by: Ben Browning <56071+bbrowning@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Ben Browning committed
Jul 22, 2026 at 16:10 UTC
3de4b2bf3c477513afff4a58680eb00d557bb53a
3 files changed
+72
-15
tests/parser/engine/test_deepseek_v4.py
+42
@@ -920,3 +920,45 @@ class TestDelegatingParserLargeDelta:
920
assert output.tool_calls[0]["name"] == "get_weather"
921
args = json.loads(output.tool_calls[0]["arguments"])
922
assert args == {"location": "Berlin"}
923
+
924
+ @pytest.mark.parametrize(
925
+ "chunk_size",
926
+ [1, 2, 3, 5, None],
927
+ ids=lambda c: f"chunk={c}",
928
+ )
929
+ def test_eos_not_leaked_when_reasoning_never_ends(self, chunk_size):
930
+ """EOS must not leak into reasoning_content when the model never
931
+ emits </think> (generation ends while still in REASONING state)."""
932
+ eos_text = "<|end▁of▁sentence|>"
933
+ eos_id = 128801
934
+ vocab = {
935
+ **_DSV4_FULL_VOCAB,
936
+ eos_text: eos_id,
937
+ }
938
+
939
+ reasoning_text = "Good morning! How can I help you today?"
940
+ tokens: list[tuple[int, str]] = []
941
+ tid = 100
942
+ for word in reasoning_text.split(" "):
943
+ prefix = " " if tokens else ""
944
+ tokens.append((tid, prefix + word))
945
+ tid += 1
946
+ tokens.append((eos_id, eos_text))
947
+
948
+ tokenizer = MockTokenizer(vocab=vocab, tokens=tokens)
949
+ parser = _DeepSeekV4Delegating(
950
+ tokenizer,
951
+ chat_template_kwargs={"thinking": True},
952
+ )
953
+ deltas = replay_streaming(
954
+ parser,
955
+ tokens,
956
+ chunk_size=chunk_size,
957
+ finished_on_last=True,
958
+ )
959
+ output = collect_output(deltas)
960
+
961
+ assert reasoning_text in output.reasoning
962
+ assert eos_text not in output.reasoning
963
+ assert output.content == ""
964
+ assert output.tool_calls == []
tests/parser/engine/test_parser_engine.py
+29
-7
@@ -1624,19 +1624,41 @@ class TestDropSpecialTokens:
1624
assert delta is not None
1625
assert "<bos>" in delta.reasoning
1626
1627
- def test_drops_suppressed_with_skip_tool_parsing(self):
1628
- """When skip_tool_parsing is active, drop tokens are preserved
1629
- as content so a later tool-call pass can see them."""
1627
+ def test_drops_applied_with_skip_tool_parsing(self):
1628
+ """Drop tokens are always dropped, even with skip_tool_parsing.
1629
+ DROP_TERMINALs have no transitions by construction, so no parser
1630
+ pass can use them."""
1631
+ for initial_state in (ParserState.REASONING, ParserState.CONTENT):
1632
+ engine = _make_engine(
1633
+ vocab=_DROP_VOCAB,
1634
+ special_tokens=list(_DROP_VOCAB.keys()),
1635
+ )
1636
+ engine._engine.skip_tool_parsing = True
1637
+ engine._engine.reset(initial_state=initial_state)
1638
+ events = engine._engine.feed("hello<bos>world", [72, 204, 73])
1639
+ delta = engine._events_to_delta(events)
1640
+ assert delta is not None
1641
+ output = (delta.reasoning or "") + (delta.content or "")
1642
+ assert "<bos>" not in output, f"<bos> leaked in state {initial_state}"
1643
+
1644
+ def test_transitions_unaffected_by_drop_in_reasoning_with_skip_tool_parsing(self):
1645
+ """With skip_tool_parsing in REASONING state, drop tokens are
1646
+ removed but configured terminals still fire their transitions."""
1647
engine = _make_engine(
1648
vocab=_DROP_VOCAB,
1649
special_tokens=list(_DROP_VOCAB.keys()),
1650
)
1651
engine._engine.skip_tool_parsing = True
1652
engine._engine.reset()
1636
- events = engine._engine.feed("hello<bos>world", [72, 204, 73])
1637
- delta = engine._events_to_delta(events)
1638
- assert delta is not None
1639
- assert "<bos>" in delta.reasoning
1653
+ events = engine._engine.feed("thought<bos></think>answer", [72, 204, 201, 73])
1654
+ types = [e.type for e in events]
1655
+ assert EventType.REASONING_CHUNK in types
1656
+ assert EventType.REASONING_END in types
1657
+ assert EventType.TEXT_CHUNK in types
1658
+ reasoning_text = "".join(
1659
+ e.value for e in events if e.type == EventType.REASONING_CHUNK
1660
+ )
1661
+ assert "<bos>" not in reasoning_text
1662
1663
def test_drops_in_tool_args_state(self):
1664
"""Drop tokens in TOOL_ARGS state are silently discarded."""
vllm/parser/engine/streaming_parser_engine.py
+1
-8
@@ -304,14 +304,7 @@ class StreamingParserEngine:
304
transition = self.config.transitions.get(key)
305
306
if transition is None:
307
- if (
308
- self._has_drops
309
- and terminal == DROP_TERMINAL
310
- # Preserve drop tokens when skip_tool_parsing is active so
311
- # the reasoning pass doesn't silently remove tokens that a
312
- # later tool-call pass might need to see.
313
- and not self.skip_tool_parsing
314
- ):
307
+ if self._has_drops and terminal == DROP_TERMINAL:
308
return []
309
return self._emit_for_state(value)
310