[Parser][Bugfix] Ensure tool call or other special tokens don't leak in non-streaming tool parsing (#46875)
Signed-off-by: Ben Browning <bbrownin@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Ben Browning committed
Jun 30, 2026 at 13:46 UTC
25671cb520e01c7b1c0eabeb65f38a6cf9052b4c
14 files changed
+783
-219
tests/parser/engine/conftest.py
+14
-2
@@ -15,8 +15,17 @@ def should_do_global_cleanup_after_test() -> bool:
15
return False
16
17
18
-def make_mock_tokenizer(vocab: dict[str, int]) -> MagicMock:
19
- """Create a mock tokenizer with the given special-token vocabulary.
18
+def make_mock_tokenizer(
19
+ vocab: dict[str, int],
20
+ special_tokens: list[str] | None = None,
21
+) -> MagicMock:
22
+ """Create a mock tokenizer with the given vocabulary.
23
+
24
+ Args:
25
+ vocab: Mapping of token text to token ID.
26
+ special_tokens: Which tokens to mark as special. When ``None``
27
+ (the default), every key in *vocab* is treated as special —
28
+ convenient when the vocab only contains delimiter tokens.
29
30
The returned mock supports get_vocab(), encode(), and decode().
31
decode() maps known token IDs back to their text and falls back to
@@ -29,6 +38,9 @@ def make_mock_tokenizer(vocab: dict[str, int]) -> MagicMock:
38
tokenizer.decode.side_effect = lambda ids: "".join(
39
id_to_text.get(i, chr(i) if i < 128 else f"<{i}>") for i in ids
40
)
41
+ st = special_tokens if special_tokens is not None else list(vocab.keys())
42
+ tokenizer.all_special_tokens = st
43
+ tokenizer.all_special_ids = [vocab[t] for t in st if t in vocab]
44
return tokenizer
45
46
tests/parser/engine/replay_harness.py
+41
@@ -62,6 +62,7 @@ class MockTokenizer:
62
"eos_token_id",
63
"bos_token_id",
64
"pad_token_id",
65
+ "_all_special_tokens",
66
)
67
68
def __init__(
@@ -73,6 +74,7 @@ class MockTokenizer:
74
self._token_ids = [tid for tid, _ in tokens]
75
self._token_decode_map = {tid: text for tid, text in tokens}
76
self._special_ids = set(vocab.values())
77
+ self._all_special_tokens = list(vocab.keys())
78
self.eos_token_id = None
79
self.bos_token_id = None
80
self.pad_token_id = None
@@ -83,6 +85,14 @@ class MockTokenizer:
85
def get_vocab(self) -> dict[str, int]:
86
return self._vocab
87
88
+ @property
89
+ def all_special_tokens(self) -> list[str]:
90
+ return self._all_special_tokens
91
+
92
+ @property
93
+ def all_special_ids(self) -> list[int]:
94
+ return [self._vocab[t] for t in self._all_special_tokens if t in self._vocab]
95
+
96
def encode(self, text: str, **kwargs) -> list[int]:
97
return self._token_ids
98
@@ -117,6 +127,37 @@ def _test_request(
127
)
128
129
130
+DUMMY_TOOLS = [
131
+ {
132
+ "type": "function",
133
+ "function": {"name": "stub", "parameters": {"type": "object"}},
134
+ },
135
+]
136
+
137
+
138
+def parse_non_streaming(
139
+ parser,
140
+ sample: Sample,
141
+ request: ChatCompletionRequest,
142
+) -> ParseOutput:
143
+ """Run ``parser.parse()`` and return a :class:`ParseOutput`."""
144
+ full_text = "".join(text for _, text in sample.tokens)
145
+ reasoning, content, tool_calls = parser.parse(
146
+ full_text,
147
+ request,
148
+ enable_auto_tools=True,
149
+ )
150
+ tc_list: list[dict] = []
151
+ if tool_calls:
152
+ for tc in tool_calls:
153
+ tc_list.append({"name": tc.name, "arguments": tc.arguments})
154
+ return ParseOutput(
155
+ reasoning=reasoning or "",
156
+ content=content or "",
157
+ tool_calls=tc_list,
158
+ )
159
+
160
+
161
def replay_streaming(
162
parser,
163
tokens: list[tuple[int, str]],
tests/parser/engine/test_delegating_replay.py
+52
@@ -20,10 +20,14 @@ from pydantic import TypeAdapter
20
21
from tests.parser.engine.replay_harness import (
22
CHUNK_SIZES,
23
+ DUMMY_TOOLS,
24
MockTokenizer,
25
+ _test_request,
26
+ assert_no_terminal_leakage,
27
assert_parse_output,
28
collect_output,
29
make_mock_tokenizer,
30
+ parse_non_streaming,
31
replay_streaming,
32
)
33
from tests.parser.engine.trace_builder import _BUILDERS, build_samples
@@ -142,3 +146,51 @@ def test_delegating_replay(parser_cls, sample, chunk_size):
146
)
147
output = collect_output(deltas)
148
assert_parse_output(output, sample)
149
+
150
+
151
+_TOOL_CALL_SAMPLES = [
152
+ (p.parser_cls, p.name, s)
153
+ for p in _PAIRINGS
154
+ for s in p.samples
155
+ if s.expected_tool_calls
156
+]
157
+
158
+
159
+@pytest.mark.parametrize(
160
+ "parser_cls,parser_name,sample",
161
+ _TOOL_CALL_SAMPLES,
162
+ ids=lambda v: v.id if hasattr(v, "id") else "",
163
+)
164
+def test_delegating_parse_tool_choice_none(parser_cls, parser_name, sample):
165
+ """Non-streaming parse() with tool_choice='none' via DelegatingParser
166
+ must not leak special tokens into content."""
167
+ tokenizer = make_mock_tokenizer(sample)
168
+ validated_tools = (
169
+ _TOOLS_VALIDATOR.validate_python(sample.tools) if sample.tools else None
170
+ )
171
+ parser = parser_cls(
172
+ tokenizer,
173
+ validated_tools,
174
+ chat_template_kwargs=sample.chat_template_kwargs,
175
+ )
176
+
177
+ request = _test_request(tools=DUMMY_TOOLS)
178
+ request.tool_choice = "none"
179
+
180
+ output = parse_non_streaming(parser, sample, request)
181
+
182
+ assert output.tool_calls == [], (
183
+ f"Expected no tool calls but got {output.tool_calls}"
184
+ )
185
+
186
+ cfg = parser._tool_parser._parser_engine.parser_engine_config
187
+ terminals = sorted(
188
+ v
189
+ for v in set(cfg.terminals.values()) | set(cfg.token_id_terminals.values())
190
+ if len(v) > 1
191
+ )
192
+ assert_no_terminal_leakage(
193
+ output,
194
+ terminals,
195
+ context=f"parser={parser_name}",
196
+ )
tests/parser/engine/test_gemma4_streaming_reasoning.py
+141
-12
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock
7
8
import pytest
9
10
+from tests.parser.engine.conftest import make_mock_tokenizer
11
from tests.parser.engine.streaming_helpers import (
12
collect_content,
13
collect_function_name,
@@ -57,6 +58,8 @@ def _make_tokenizer(sequence: list[tuple[int, str]]) -> MagicMock:
58
return "".join(parts)
59
60
tokenizer.decode.side_effect = decode
61
+ tokenizer.all_special_tokens = list(SPECIAL_TOKEN_MAP.values())
62
+ tokenizer.all_special_ids = list(SPECIAL_TOKEN_MAP.keys())
63
return tokenizer
64
65
@@ -657,19 +660,15 @@ class TestGemma4ReasoningTruncationWithHoldback:
660
@pytest.fixture
661
def tool_call_tokenizer():
662
"""Mock tokenizer with Gemma4 special token vocab."""
660
- tokenizer = MagicMock()
661
- tokenizer.encode.return_value = [1, 2, 3]
662
- tokenizer.get_vocab.return_value = {
663
- "<|tool_call>": TOOL_CALL_START_ID,
664
- "<tool_call|>": TOOL_CALL_END_ID,
665
- "<|channel>": CHANNEL_START_ID,
666
- "<channel|>": CHANNEL_END_ID,
667
- '<|"|>': QUOTED_ID,
668
- }
669
- tokenizer.decode.side_effect = lambda ids: "".join(
670
- SPECIAL_TOKEN_MAP.get(i, chr(i) if i < 128 else f"<{i}>") for i in ids
663
+ return make_mock_tokenizer(
664
+ vocab={
665
+ "<|tool_call>": TOOL_CALL_START_ID,
666
+ "<tool_call|>": TOOL_CALL_END_ID,
667
+ "<|channel>": CHANNEL_START_ID,
668
+ "<channel|>": CHANNEL_END_ID,
669
+ '<|"|>': QUOTED_ID,
670
+ },
671
)
672
- return tokenizer
672
673
674
@pytest.fixture
@@ -1407,3 +1406,133 @@ class TestBareThoughtWithoutChannelOpener:
1406
assert reasoning == ""
1407
assert content == ""
1408
assert len(tool_calls) == 0
1409
+
1410
+
1411
+# ── Regression: commas inside <|"|>-delimited string values ─────────
1412
+#
1413
+# _make_tokenizer sets all_special_tokens, which activates the auto-drop
1414
+# mechanism in _build_drop_info. If <|"|> is not in configured_texts,
1415
+# it gets silently dropped and commas inside string values become field
1416
+# separators, e.g. "San Francisco, CA" → {"location": "San Francisco"}.
1417
+
1418
+
1419
+COMMA_TOKEN_SEQUENCE: list[tuple[int, str]] = [
1420
+ (TOOL_CALL_START_ID, "<|tool_call>"),
1421
+ (4000, "call"),
1422
+ (4001, ":"),
1423
+ (4002, "get_weather"),
1424
+ (4003, "{"),
1425
+ (4004, "location"),
1426
+ (4005, ":"),
1427
+ (QUOTED_ID, '<|"|>'),
1428
+ (4006, "San Francisco"),
1429
+ (4007, ", CA"),
1430
+ (QUOTED_ID, '<|"|>'),
1431
+ (4008, ","),
1432
+ (4009, "unit"),
1433
+ (4010, ":"),
1434
+ (QUOTED_ID, '<|"|>'),
1435
+ (4011, "celsius"),
1436
+ (QUOTED_ID, '<|"|>'),
1437
+ (4012, "}"),
1438
+ (TOOL_CALL_END_ID, "<tool_call|>"),
1439
+]
1440
+
1441
+MULTI_COMMA_TOKEN_SEQUENCE: list[tuple[int, str]] = [
1442
+ (TOOL_CALL_START_ID, "<|tool_call>"),
1443
+ (4000, "call"),
1444
+ (4001, ":"),
1445
+ (4020, "send_message"),
1446
+ (4003, "{"),
1447
+ (4021, "destination"),
1448
+ (4005, ":"),
1449
+ (QUOTED_ID, '<|"|>'),
1450
+ (4022, "456 Oakwood Avenue"),
1451
+ (4023, ", Rivermist"),
1452
+ (4024, ", 83214"),
1453
+ (QUOTED_ID, '<|"|>'),
1454
+ (4012, "}"),
1455
+ (TOOL_CALL_END_ID, "<tool_call|>"),
1456
+]
1457
+
1458
+
1459
+class TestCommaInStringValueRegression:
1460
+ """Regression: <|"|> delimiters must not be auto-dropped.
1461
+
1462
+ When _build_drop_info discovers <|"|> as a special token and it is
1463
+ not in configured_texts, the delimiter is silently removed. Without
1464
+ it, _parse_gemma4_args treats commas inside string values as field
1465
+ separators.
1466
+ """
1467
+
1468
+ @pytest.fixture
1469
+ def comma_tokenizer(self):
1470
+ return _make_tokenizer(COMMA_TOKEN_SEQUENCE)
1471
+
1472
+ @pytest.fixture
1473
+ def comma_parser(self, comma_tokenizer):
1474
+ return Gemma4Parser(comma_tokenizer)
1475
+
1476
+ @pytest.fixture
1477
+ def multi_comma_tokenizer(self):
1478
+ return _make_tokenizer(MULTI_COMMA_TOKEN_SEQUENCE)
1479
+
1480
+ @pytest.fixture
1481
+ def multi_comma_parser(self, multi_comma_tokenizer):
1482
+ return Gemma4Parser(multi_comma_tokenizer)
1483
+
1484
+ def test_batched_streaming_comma_in_value(
1485
+ self, comma_parser, comma_tokenizer, request_obj
1486
+ ):
1487
+ results = _stream_tokens_batched(
1488
+ comma_parser,
1489
+ comma_tokenizer,
1490
+ request_obj,
1491
+ batch_size=1,
1492
+ prompt_token_ids=[],
1493
+ )
1494
+ _, _, tool_calls = _collect_fields(results)
1495
+ assert len(tool_calls) > 0
1496
+ args_text = "".join(
1497
+ tc.function.arguments
1498
+ for tc in tool_calls
1499
+ if tc.function and tc.function.arguments
1500
+ )
1501
+ parsed = json.loads(args_text)
1502
+ assert parsed["location"] == "San Francisco, CA"
1503
+ assert parsed["unit"] == "celsius"
1504
+
1505
+ def test_batched_streaming_multiple_commas(
1506
+ self, multi_comma_parser, multi_comma_tokenizer, request_obj
1507
+ ):
1508
+ results = _stream_tokens_batched(
1509
+ multi_comma_parser,
1510
+ multi_comma_tokenizer,
1511
+ request_obj,
1512
+ batch_size=1,
1513
+ prompt_token_ids=[],
1514
+ )
1515
+ _, _, tool_calls = _collect_fields(results)
1516
+ assert len(tool_calls) > 0
1517
+ args_text = "".join(
1518
+ tc.function.arguments
1519
+ for tc in tool_calls
1520
+ if tc.function and tc.function.arguments
1521
+ )
1522
+ parsed = json.loads(args_text)
1523
+ assert parsed["destination"] == "456 Oakwood Avenue, Rivermist, 83214"
1524
+
1525
+ def test_non_streaming_comma_in_value(self, comma_parser, request_obj):
1526
+ text = "".join(text for _, text in COMMA_TOKEN_SEQUENCE)
1527
+ result = comma_parser.extract_tool_calls(text, request_obj)
1528
+ assert result.tools_called is True
1529
+ args = json.loads(result.tool_calls[0].function.arguments)
1530
+ assert args["location"] == "San Francisco, CA"
1531
+ assert args["unit"] == "celsius"
1532
+
1533
+ def test_non_streaming_multiple_commas(self, multi_comma_parser, request_obj):
1534
+ text = "".join(text for _, text in MULTI_COMMA_TOKEN_SEQUENCE)
1535
+ result = multi_comma_parser.extract_tool_calls(text, request_obj)
1536
+ assert result.tools_called is True
1537
+ args = json.loads(result.tool_calls[0].function.arguments)
1538
+ assert args["destination"] == "456 Oakwood Avenue, Rivermist, 83214"
tests/parser/engine/test_parser_engine.py
+182
-1
@@ -119,8 +119,10 @@ def _hermes_config() -> ParserEngineConfig:
119
def _make_engine(
120
config: ParserEngineConfig | None = None,
121
tools: list | None = None,
122
+ vocab: dict[str, int] | None = None,
123
+ special_tokens: list[str] | None = None,
124
) -> ParserEngine:
123
- tokenizer = make_mock_tokenizer(_VOCAB)
125
+ tokenizer = make_mock_tokenizer(vocab or _VOCAB, special_tokens=special_tokens)
126
cfg = config or _combined_config()
127
return ParserEngine(
128
tokenizer,
@@ -843,6 +845,7 @@ class TestParseTokenIdPassthrough:
845
846
assert content is not None
847
assert "<tool_call>" in content
848
+ assert content == "Use <tool_call> to call tools."
849
assert tool_calls is not None
850
assert len(tool_calls) == 1
851
assert tool_calls[0].name == "f"
@@ -1490,3 +1493,181 @@ class TestCoercionInstabilityRegression:
1493
assert parsed["val"] == "4e"
1494
assert isinstance(parsed["val"], str)
1495
assert parsed["extra"] == "ok"
1496
+
1497
+
1498
+_DROP_VOCAB: dict[str, int] = {
1499
+ **_VOCAB,
1500
+ "<bos>": 204,
1501
+ "<eos>": 205,
1502
+}
1503
+
1504
+
1505
+class TestDropSpecialTokens:
1506
+ """Special token dropping via the __DROP__ terminal mechanism."""
1507
+
1508
+ def test_drops_special_token_by_id_from_content(self):
1509
+ """A special token (not a configured terminal) is dropped when
1510
+ it arrives as its actual token ID."""
1511
+ config = ParserEngineConfig(
1512
+ name="drop_content_test",
1513
+ terminals={},
1514
+ token_id_terminals={},
1515
+ transitions={},
1516
+ initial_state=ParserState.CONTENT,
1517
+ content_events={ParserState.CONTENT: EventType.TEXT_CHUNK},
1518
+ )
1519
+ engine = _make_engine(
1520
+ config=config,
1521
+ vocab=_DROP_VOCAB,
1522
+ special_tokens=list(_DROP_VOCAB.keys()),
1523
+ )
1524
+ engine._engine.reset()
1525
+ events = engine._engine.feed("hello<bos>world", [72, 204, 73])
1526
+ delta = engine._events_to_delta(events)
1527
+ assert delta is not None
1528
+ assert "<bos>" not in delta.content
1529
+ assert delta.content == "helloworld"
1530
+
1531
+ def test_drops_special_token_by_id_from_reasoning(self):
1532
+ engine = _make_engine(
1533
+ vocab=_DROP_VOCAB,
1534
+ special_tokens=list(_DROP_VOCAB.keys()),
1535
+ )
1536
+ engine._engine.reset()
1537
+ events = engine._engine.feed("thinking<eos>more", [72, 205, 73])
1538
+ delta = engine._events_to_delta(events)
1539
+ assert delta is not None
1540
+ assert "<eos>" not in delta.reasoning
1541
+ assert delta.reasoning == "thinkingmore"
1542
+
1543
+ def test_drops_via_text_fallback_when_no_token_ids(self):
1544
+ """When no token IDs are provided, text-based lexer catches
1545
+ drop tokens as a fallback."""
1546
+ engine = _make_engine(
1547
+ vocab=_DROP_VOCAB,
1548
+ special_tokens=list(_DROP_VOCAB.keys()),
1549
+ )
1550
+ engine._engine.reset()
1551
+ events = engine._engine.feed("hello<bos>world", [])
1552
+ delta = engine._events_to_delta(events)
1553
+ assert delta is not None
1554
+ assert "<bos>" not in delta.reasoning
1555
+
1556
+ def test_regular_tokens_spelling_special_survive(self):
1557
+ """Regular tokens that spell out a drop-token string survive
1558
+ when token IDs prove they are not the special token."""
1559
+ vocab = {**_DROP_VOCAB, "h": 72, "<": 73, "bos": 74, ">": 75, "w": 76}
1560
+ engine = _make_engine(
1561
+ vocab=vocab,
1562
+ special_tokens=list(_DROP_VOCAB.keys()),
1563
+ )
1564
+ engine._engine.reset()
1565
+ # Feed regular token IDs (73, 74, 75) that spell <bos>,
1566
+ # not the special token ID 204.
1567
+ events = engine._engine.feed("h<bos>w", [72, 73, 74, 75, 76])
1568
+ delta = engine._events_to_delta(events)
1569
+ assert delta is not None
1570
+ assert "<bos>" in delta.reasoning
1571
+
1572
+ def test_configured_terminal_not_treated_as_drop(self):
1573
+ """Tokens already in config.terminals (like <think>) are handled
1574
+ by the state machine, not the drop mechanism."""
1575
+ engine = _make_engine(
1576
+ vocab=_DROP_VOCAB,
1577
+ special_tokens=list(_DROP_VOCAB.keys()),
1578
+ )
1579
+ # Start from CONTENT so <think> triggers REASONING_START
1580
+ engine._engine.reset(initial_state=ParserState.CONTENT)
1581
+ events = engine._engine.feed("<think>", [200])
1582
+ has_reasoning_start = any(e.type == EventType.REASONING_START for e in events)
1583
+ assert has_reasoning_start
1584
+
1585
+ def test_tool_name_not_affected_by_drops(self):
1586
+ engine = _make_engine(
1587
+ vocab=_DROP_VOCAB,
1588
+ special_tokens=list(_DROP_VOCAB.keys()),
1589
+ )
1590
+ engine._engine.reset()
1591
+ events = [
1592
+ SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
1593
+ SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0),
1594
+ SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"city": "NYC"}', tool_index=0),
1595
+ SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
1596
+ ]
1597
+ delta = engine._events_to_delta(events)
1598
+ assert delta is not None
1599
+ assert delta.tool_calls is not None
1600
+ names = [
1601
+ tc.function.name
1602
+ for tc in delta.tool_calls
1603
+ if tc.function and tc.function.name
1604
+ ]
1605
+ assert "get_weather" in names
1606
+
1607
+ def test_adjacent_drop_tokens_by_id(self):
1608
+ engine = _make_engine(
1609
+ vocab=_DROP_VOCAB,
1610
+ special_tokens=list(_DROP_VOCAB.keys()),
1611
+ )
1612
+ engine._engine.reset()
1613
+ events = engine._engine.feed("<bos><eos>", [204, 205])
1614
+ delta = engine._events_to_delta(events)
1615
+ assert delta is None
1616
+
1617
+ def test_no_special_tokens_means_no_drops(self):
1618
+ """Engine with no special tokens passes all text through."""
1619
+ engine = _make_engine(special_tokens=[])
1620
+ engine._engine.reset()
1621
+ events = engine._engine.feed("hello<bos>world", [])
1622
+ delta = engine._events_to_delta(events)
1623
+ assert delta is not None
1624
+ assert "<bos>" in delta.reasoning
1625
+
1626
+ def test_drops_suppressed_with_skip_tool_parsing(self):
1627
+ """When skip_tool_parsing is active, drop tokens are preserved
1628
+ as content so a later tool-call pass can see them."""
1629
+ engine = _make_engine(
1630
+ vocab=_DROP_VOCAB,
1631
+ special_tokens=list(_DROP_VOCAB.keys()),
1632
+ )
1633
+ engine._engine.skip_tool_parsing = True
1634
+ engine._engine.reset()
1635
+ events = engine._engine.feed("hello<bos>world", [72, 204, 73])
1636
+ delta = engine._events_to_delta(events)
1637
+ assert delta is not None
1638
+ assert "<bos>" in delta.reasoning
1639
+
1640
+ def test_drops_in_tool_args_state(self):
1641
+ """Drop tokens in TOOL_ARGS state are silently discarded."""
1642
+ vocab = {**_DROP_VOCAB, '{"a":1}': 300}
1643
+ engine = _make_engine(
1644
+ vocab=vocab,
1645
+ special_tokens=list(_DROP_VOCAB.keys()),
1646
+ )
1647
+ engine._engine.reset(initial_state=ParserState.CONTENT)
1648
+ events = engine._engine.feed(
1649
+ '<tool_call>{"a":1}<bos></tool_call>',
1650
+ [202, 300, 204, 203],
1651
+ )
1652
+ arg_chunks = [e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK]
1653
+ combined = "".join(arg_chunks)
1654
+ assert "<bos>" not in combined
1655
+ assert '{"a":1}' in combined
1656
+
1657
+ def test_mixed_configured_and_drop_terminals(self):
1658
+ """Configured terminals trigger transitions while drop terminals
1659
+ are silently removed in the same stream."""
1660
+ engine = _make_engine(
1661
+ vocab=_DROP_VOCAB,
1662
+ special_tokens=list(_DROP_VOCAB.keys()),
1663
+ )
1664
+ engine._engine.reset()
1665
+ events = engine._engine.feed("thought<bos></think>answer", [72, 204, 201, 73])
1666
+ types = [e.type for e in events]
1667
+ assert EventType.REASONING_CHUNK in types
1668
+ assert EventType.REASONING_END in types
1669
+ assert EventType.TEXT_CHUNK in types
1670
+ reasoning_text = "".join(
1671
+ e.value for e in events if e.type == EventType.REASONING_CHUNK
1672
+ )
1673
+ assert "<bos>" not in reasoning_text
tests/parser/engine/test_replay.py
+217
-29
@@ -18,18 +18,21 @@ from typing import NamedTuple
18
import pytest
19
20
from tests.parser.engine.replay_harness import (
21
+ DUMMY_TOOLS,
22
MockTokenizer,
23
_test_request,
24
assert_no_terminal_leakage,
25
assert_parse_output,
26
collect_output,
27
make_mock_tokenizer,
28
+ parse_non_streaming,
29
replay_streaming,
30
replay_with_text_holdback,
31
)
32
from tests.parser.engine.trace_builder import _BUILDERS, build_samples
33
from vllm.parser.engine import registered_adapters as _adapters_mod
34
from vllm.parser.engine.parser_engine import ParserEngine
35
+from vllm.parser.engine.parser_engine_config import ParserState
36
37
# ── Parser discovery ─────────────────────────────────────────────────
38
@@ -78,7 +81,11 @@ def _discover_parsers() -> list[_ParserInfo]:
81
terminals=sorted(v for v in all_vals if len(v) > 1),
82
tool_end=tool_end,
83
think_end=cfg.terminals.get("THINK_END", ""),
81
- tool_start=cfg.terminals.get("TOOL_START", ""),
84
+ tool_start=(
85
+ cfg.terminals["TOOL_SECTION_START"]
86
+ if (ParserState.CONTENT, "TOOL_SECTION_START") in cfg.transitions
87
+ else cfg.terminals.get("TOOL_START", "")
88
+ ),
89
)
90
)
91
if missing_builders:
@@ -287,16 +294,18 @@ _TOOL_CALL_SAMPLES = [
294
]
295
296
290
-def _suppressed_expectations(
291
- sample, think_end: str, tool_start: str
297
+def _tool_suppression_expectations(
298
+ sample, think_end: str, tool_start: str, *, include_tool_block: bool
299
) -> tuple[str, str]:
293
- """Compute expected (reasoning, content) when tools are suppressed.
300
+ """Expected (reasoning, content) when tool calls are not extracted.
301
295
- When an explicit reasoning-end delimiter is present, reasoning ends
296
- there and the tool call block becomes content. When reasoning ends
297
- implicitly (the tool-start token triggers both REASONING_END and
298
- TOOL_CALL_START), reasoning still ends at the tool start and the raw
299
- tool call block becomes content text.
302
+ With ``include_tool_block=True`` (skip_tool_parsing / reasoning
303
+ adapter first pass), tool terminal text is preserved as content so
304
+ a second-pass parser can see it.
305
+
306
+ With ``include_tool_block=False`` (_suppress_tool_calls /
307
+ tool_choice='none'), the state machine consumes tool blocks and
308
+ only non-tool content survives.
309
"""
310
full_text = "".join(text for _, text in sample.tokens)
311
reasoning = sample.expected_reasoning
@@ -307,36 +316,42 @@ def _suppressed_expectations(
316
if think_end:
317
pos = after_reasoning.find(think_end)
318
if pos >= 0:
310
- return (reasoning, after_reasoning[pos + len(think_end) :])
319
+ if include_tool_block:
320
+ return (reasoning, after_reasoning[pos + len(think_end) :])
321
+ after_reasoning = after_reasoning[pos + len(think_end) :]
322
if tool_start:
323
pos = after_reasoning.find(tool_start)
324
if pos >= 0:
314
- return (reasoning, after_reasoning[pos:])
315
- return (full_text, "")
316
-
317
-
318
-_DUMMY_TOOLS = [
319
- {
320
- "type": "function",
321
- "function": {"name": "stub", "parameters": {"type": "object"}},
322
- }
323
-]
325
+ if include_tool_block:
326
+ return (reasoning, after_reasoning[pos:])
327
+ return (reasoning, after_reasoning[:pos])
328
+ if include_tool_block:
329
+ return (full_text, "")
330
+ return (reasoning, after_reasoning)
331
332
333
@pytest.mark.parametrize("chunk_size", [1, 5, None], ids=lambda c: f"chunk{c}")
334
+@pytest.mark.parametrize(
335
+ "mode",
336
+ ["skip_tool_parsing", "suppress_tool_calls"],
337
+ ids=["skip_tool_parsing", "suppress_tool_calls"],
338
+)
339
@pytest.mark.parametrize(
340
"parser_cls,sample,think_end,tool_start",
341
_TOOL_CALL_SAMPLES,
342
ids=lambda v: v.id if hasattr(v, "id") else getattr(v, "__name__", ""),
343
)
332
-class TestSkipToolParsingReplay:
333
- """Replay with skip_tool_parsing=True (tool_choice='none').
344
+class TestToolCallFilteringReplay:
345
+ """Replay with tool calls not extracted, in both filtering modes.
346
+
347
+ ``skip_tool_parsing`` (reasoning adapter first pass): tool terminal
348
+ text is preserved as content for a second-pass tool parser.
349
335
- Verifies that reasoning is extracted normally and the raw tool call
336
- block appears as content text with no tool calls parsed.
350
+ ``suppress_tool_calls`` (tool_choice='none'): tool call blocks are
351
+ consumed by the state machine and do not leak into content.
352
"""
353
339
- def test_replay(self, parser_cls, sample, think_end, tool_start, chunk_size):
354
+ def test_replay(self, parser_cls, sample, think_end, tool_start, mode, chunk_size):
355
tokenizer = make_mock_tokenizer(sample)
356
kwargs = {}
357
if sample.chat_template_kwargs:
@@ -344,8 +359,11 @@ class TestSkipToolParsingReplay:
359
parser = parser_cls(tokenizer, **kwargs)
360
361
request = _test_request()
347
- request.tool_choice = "none"
348
- request.tools = _DUMMY_TOOLS
362
+ request.tools = DUMMY_TOOLS
363
+ if mode == "skip_tool_parsing":
364
+ parser.skip_tool_parsing = True
365
+ else:
366
+ request.tool_choice = "none"
367
368
all_ids = [tid for tid, _ in sample.tokens]
369
all_texts = [text for _, text in sample.tokens]
@@ -370,10 +388,51 @@ class TestSkipToolParsingReplay:
388
389
output = collect_output(results)
390
373
- expected_reasoning, expected_content = _suppressed_expectations(
374
- sample, think_end, tool_start
391
+ include_block = mode == "skip_tool_parsing"
392
+ expected_reasoning, expected_content = _tool_suppression_expectations(
393
+ sample, think_end, tool_start, include_tool_block=include_block
394
)
395
396
+ assert output.reasoning == expected_reasoning, (
397
+ f"Reasoning mismatch (mode={mode}):\n"
398
+ f" expected: {expected_reasoning!r}\n"
399
+ f" actual: {output.reasoning!r}"
400
+ )
401
+ assert output.tool_calls == [], (
402
+ f"Expected no tool calls (mode={mode}) but got {output.tool_calls}"
403
+ )
404
+ assert output.content == expected_content, (
405
+ f"Content mismatch (mode={mode}):\n"
406
+ f" expected: {expected_content!r}\n"
407
+ f" actual: {output.content!r}"
408
+ )
409
+
410
+
411
+@pytest.mark.parametrize(
412
+ "parser_cls,sample,think_end,tool_start",
413
+ _TOOL_CALL_SAMPLES,
414
+ ids=lambda v: v.id if hasattr(v, "id") else getattr(v, "__name__", ""),
415
+)
416
+class TestToolCallFilteringNonStreaming:
417
+ """Non-streaming parse() with tool_choice='none' must suppress tool
418
+ calls and not leak special tokens into content."""
419
+
420
+ def test_parse(self, parser_cls, sample, think_end, tool_start):
421
+ tokenizer = make_mock_tokenizer(sample)
422
+ kwargs = {}
423
+ if sample.chat_template_kwargs:
424
+ kwargs["chat_template_kwargs"] = sample.chat_template_kwargs
425
+ parser = parser_cls(tokenizer, **kwargs)
426
+
427
+ request = _test_request()
428
+ request.tools = DUMMY_TOOLS
429
+ request.tool_choice = "none"
430
+
431
+ output = parse_non_streaming(parser, sample, request)
432
+
433
+ expected_reasoning, expected_content = _tool_suppression_expectations(
434
+ sample, think_end, tool_start, include_tool_block=False
435
+ )
436
assert output.reasoning == expected_reasoning, (
437
f"Reasoning mismatch:\n"
438
f" expected: {expected_reasoning!r}\n"
@@ -389,6 +448,135 @@ class TestSkipToolParsingReplay:
448
)
449
450
451
+_WS_TOOL_SAMPLES = [(t[0], t[1]) for t in _TOOL_CALL_SAMPLES if "whitespace" in t[1].id]
452
+
453
+
454
+@pytest.mark.parametrize(
455
+ "parser_cls,sample",
456
+ _WS_TOOL_SAMPLES,
457
+ ids=lambda v: v.id if hasattr(v, "id") else getattr(v, "__name__", ""),
458
+)
459
+class TestToolChoiceNoneStreamingParity:
460
+ """Streaming and non-streaming must return the same content
461
+ when tool_choice='none' suppresses tool calls."""
462
+
463
+ def test_content_matches(self, parser_cls, sample):
464
+ tokenizer = make_mock_tokenizer(sample)
465
+ kwargs = {}
466
+ if sample.chat_template_kwargs:
467
+ kwargs["chat_template_kwargs"] = sample.chat_template_kwargs
468
+ request = _test_request()
469
+ request.tools = DUMMY_TOOLS
470
+ request.tool_choice = "none"
471
+
472
+ ns_output = parse_non_streaming(
473
+ parser_cls(tokenizer, **kwargs),
474
+ sample,
475
+ request,
476
+ )
477
+
478
+ s_parser = parser_cls(tokenizer, **kwargs)
479
+ results = []
480
+ for i, (tid, text) in enumerate(sample.tokens):
481
+ is_last = i == len(sample.tokens) - 1
482
+ results.append(
483
+ s_parser.parse_delta(
484
+ text,
485
+ [tid],
486
+ request,
487
+ prompt_token_ids=(sample.prompt_token_ids or [])
488
+ if i == 0
489
+ else None,
490
+ finished=is_last,
491
+ )
492
+ )
493
+ s_output = collect_output(results)
494
+
495
+ assert ns_output.content == s_output.content, (
496
+ f"Streaming/non-streaming content mismatch:\n"
497
+ f" streaming: {s_output.content!r}\n"
498
+ f" non-streaming: {ns_output.content!r}"
499
+ )
500
+
501
+
502
+_DROP_TOKENS = {"<bos>": 99990, "<eos>": 99991}
503
+
504
+
505
+def _inject_drop_tokens(sample):
506
+ """Insert <bos> at stream start and <eos> between the first two tokens."""
507
+ new_vocab = {**sample.vocab, **_DROP_TOKENS}
508
+ tokens = list(sample.tokens)
509
+ tokens.insert(0, (99990, "<bos>"))
510
+ if len(tokens) >= 3:
511
+ tokens.insert(2, (99991, "<eos>"))
512
+ else:
513
+ tokens.append((99991, "<eos>"))
514
+ return dataclasses.replace(sample, vocab=new_vocab, tokens=tokens)
515
+
516
+
517
+class TestDropTokenReplay:
518
+ """Verify unconfigured special tokens are silently dropped across
519
+ all parsers and chunk sizes."""
520
+
521
+ @pytest.mark.parametrize(
522
+ "parser_info",
523
+ _PARSERS,
524
+ ids=[p.name for p in _PARSERS],
525
+ )
526
+ @pytest.mark.parametrize("chunk_size", [1, 3, None])
527
+ def test_drop_tokens_removed_from_output(self, parser_info, chunk_size):
528
+ for sample in parser_info.samples:
529
+ injected = _inject_drop_tokens(sample)
530
+ tokenizer = make_mock_tokenizer(injected)
531
+ parser = parser_info.parser_cls(
532
+ tokenizer,
533
+ tools=sample.tools,
534
+ )
535
+
536
+ results = replay_streaming(
537
+ parser,
538
+ injected.tokens,
539
+ chunk_size=chunk_size,
540
+ tools=sample.tools,
541
+ prompt_token_ids=sample.prompt_token_ids,
542
+ )
543
+ output = collect_output(results)
544
+
545
+ assert_no_terminal_leakage(
546
+ output,
547
+ list(_DROP_TOKENS.keys()),
548
+ context=f"parser={parser_info.name}, chunk={chunk_size}",
549
+ )
550
+ assert_parse_output(output, sample)
551
+
552
+
553
+class TestDropTokenNonStreaming:
554
+ """Non-streaming parse() must also strip unconfigured special tokens."""
555
+
556
+ @pytest.mark.parametrize(
557
+ "parser_info",
558
+ _PARSERS,
559
+ ids=[p.name for p in _PARSERS],
560
+ )
561
+ def test_drop_tokens_removed_from_output(self, parser_info):
562
+ for sample in parser_info.samples:
563
+ injected = _inject_drop_tokens(sample)
564
+ tokenizer = make_mock_tokenizer(injected)
565
+ parser = parser_info.parser_cls(
566
+ tokenizer,
567
+ tools=sample.tools,
568
+ )
569
+
570
+ request = _test_request(tools=sample.tools)
571
+ output = parse_non_streaming(parser, injected, request)
572
+
573
+ assert_no_terminal_leakage(
574
+ output,
575
+ list(_DROP_TOKENS.keys()),
576
+ context=f"parser={parser_info.name}",
577
+ )
578
+
579
+
580
class TestAdapterReferences:
581
"""Verify make_adapters sets reasoning/tool parser class refs on parser engine
582
parser classes so the serving layer finds them and calls adjust_request."""
tests/parser/engine/test_token_id_scanner.py
+1
-38
@@ -57,7 +57,7 @@ class TestJoinDecodedTextReturnsStr:
57
58
@pytest.fixture
59
def bare_scanner(self):
60
- return TokenIDScanner({}, tokenizer=None, drop_token_ids=set())
60
+ return TokenIDScanner({}, tokenizer=None)
61
62
def test_mixed_items(self, bare_scanner):
63
items = [
@@ -265,43 +265,6 @@ class TestHoldbackTextRecovery:
265
assert "reasoning end." in combined
266
267
268
-class TestDropTokens:
269
- def test_drop_token_with_holdback(self, tokenizer):
270
- """Drop tokens stripped; hold-back text preserved."""
271
- drop_id = 300
272
- tokenizer.decode.side_effect = lambda ids: {
273
- CHANNEL_END_ID: CHANNEL_END,
274
- drop_id: "<eos>",
275
- }.get(ids[0], "?")
276
-
277
- scanner = TokenIDScanner(
278
- token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
279
- tokenizer=tokenizer,
280
- drop_token_ids={drop_id},
281
- )
282
-
283
- result = scanner.scan(
284
- delta_text="holdback<eos>",
285
- delta_token_ids=[drop_id, CHANNEL_END_ID],
286
- )
287
-
288
- assert len(result) == 0
289
-
290
- result2 = scanner.scan(
291
- delta_text="<channel|>content",
292
- delta_token_ids=[20],
293
- )
294
- pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)]
295
- assert len(pre_lexed) == 1
296
- assert pre_lexed[0].terminal == "THINK_END"
297
- texts = [r.text for r in result2 if isinstance(r, TextChunk)]
298
- combined = "".join(texts)
299
- assert "holdback" in combined
300
- assert "<eos>" not in combined
301
-
302
- assert len(scanner.flush_pending()) == 0
303
-
304
-
268
class TestEndToEndReasoningHoldback:
269
"""End-to-end engine tests with detokenizer hold-back."""
270
tests/tool_use/test_gemma4_responses_adjust_request.py
+22
-12
@@ -100,14 +100,24 @@ def _build_chat_request(
100
class _StubTokenizer:
101
"""Minimal tokenizer stub to satisfy ``Gemma4EngineToolParser.__init__``."""
102
103
+ _VOCAB: dict[str, int] = {
104
+ "<|tool_call>": 256_000,
105
+ "<tool_call|>": 256_001,
106
+ '<|"|>': 52,
107
+ "<|channel>": 256_002,
108
+ "<channel|>": 256_003,
109
+ }
110
+
111
def get_vocab(self) -> dict[str, int]:
104
- return {
105
- "<|tool_call>": 256_000,
106
- "<tool_call|>": 256_001,
107
- '<|"|>': 52,
108
- "<|channel>": 256_002,
109
- "<channel|>": 256_003,
110
- }
112
+ return dict(self._VOCAB)
113
+
114
+ @property
115
+ def all_special_tokens(self) -> list[str]:
116
+ return list(self._VOCAB.keys())
117
+
118
+ @property
119
+ def all_special_ids(self) -> list[int]:
120
+ return list(self._VOCAB.values())
121
122
123
def test_gemma4_adjust_request_sets_skip_special_tokens_on_responses() -> None:
@@ -235,10 +245,10 @@ def test_gemma4_keeps_special_tokens_with_tools_thinking_disabled() -> None:
245
assert request.skip_special_tokens is False
246
247
238
-def test_gemma4_strips_special_tokens_when_nothing_to_preserve() -> None:
239
- """No active tools + thinking disabled: keep the default
240
- (``skip_special_tokens=True``) so stray delimiters do not leak into
241
- content.
248
+def test_gemma4_keeps_skip_special_tokens_false_when_nothing_to_preserve() -> None:
249
+ """No active tools + thinking disabled: ``skip_special_tokens`` stays
250
+ ``False`` because the parser engine's ``__DROP__`` terminal mechanism
251
+ strips unconfigured special tokens automatically.
252
"""
253
parser = Gemma4ToolParser(_StubTokenizer())
254
request = _build_chat_request(
@@ -247,4 +257,4 @@ def test_gemma4_strips_special_tokens_when_nothing_to_preserve() -> None:
257
258
parser.adjust_request(request)
259
250
- assert request.skip_special_tokens is True
260
+ assert request.skip_special_tokens is False
vllm/parser/abstract_parser.py
+6
@@ -424,6 +424,12 @@ class DelegatingParser(Parser):
424
if tool_parser is None:
425
return [], content
426
427
+ if request.tool_choice == "none":
428
+ if self._engine_based:
429
+ result = self.extract_tool_calls(content or "", request=request)
430
+ return [], result.content
431
+ return [], content
432
+
433
supports_required_and_named = tool_parser.supports_required_and_named
434
is_named_tool_choice = request.tool_choice and isinstance(
435
request.tool_choice,
vllm/parser/engine/parser_engine.py
+17
-11
@@ -116,6 +116,7 @@ class ParserEngine(Parser):
116
self._deferred_content: str = ""
117
self._deferred_reasoning: str = ""
118
self._content_has_nonws: bool = False
119
+ self._suppress_tool_calls: bool = False
120
121
self._arg_converter = parser_engine_config.arg_converter
122
self._arg_structural_chars = parser_engine_config.arg_structural_chars
@@ -398,10 +399,10 @@ class ParserEngine(Parser):
399
tools = getattr(request, "tools", None)
400
if tools:
401
self._tools = tools
401
- if not self.skip_tool_parsing:
402
+ if not self.skip_tool_parsing and not self._suppress_tool_calls:
403
tool_choice = getattr(request, "tool_choice", None)
404
if tool_choice == "none" and tools:
404
- self.skip_tool_parsing = True
405
+ self._suppress_tool_calls = True
406
407
def _strip_content_whitespace(
408
self,
@@ -642,7 +643,7 @@ class ParserEngine(Parser):
643
events = self._feed(text, token_ids)
644
events.extend(self._engine.finish())
645
645
- delta = self._events_to_delta(events)
646
+ delta = self._events_to_delta(events, finished=True)
647
tool_call_info = self._build_extracted_result()
648
649
reasoning = delta.reasoning if delta else None
@@ -701,6 +702,7 @@ class ParserEngine(Parser):
702
reasoning_parts: list[str] = []
703
704
seen_tool_event = False
705
+ suppress = self._suppress_tool_calls
706
for event in events:
707
match event.type:
708
case EventType.TEXT_CHUNK:
@@ -713,17 +715,21 @@ class ParserEngine(Parser):
715
case EventType.REASONING_END:
716
self._reasoning_ended = True
717
case EventType.TOOL_CALL_START:
716
- seen_tool_event = True
717
- self._ensure_slot(event.tool_index)
718
+ if not suppress:
719
+ seen_tool_event = True
720
+ self._ensure_slot(event.tool_index)
721
case EventType.TOOL_NAME:
719
- seen_tool_event = True
720
- self._handle_tool_name(event)
722
+ if not suppress:
723
+ seen_tool_event = True
724
+ self._handle_tool_name(event)
725
case EventType.ARG_VALUE_CHUNK:
722
- seen_tool_event = True
723
- self._handle_arg_chunk(event, tool_call_deltas)
726
+ if not suppress:
727
+ seen_tool_event = True
728
+ self._handle_arg_chunk(event, tool_call_deltas)
729
case EventType.TOOL_CALL_END:
725
- seen_tool_event = True
726
- self._handle_tool_end(event, tool_call_deltas)
730
+ if not suppress:
731
+ seen_tool_event = True
732
+ self._handle_tool_end(event, tool_call_deltas)
733
case EventType.REASONING_START:
734
pass # no delta-level effect
735
vllm/parser/engine/parser_engine_config.py
+3
-12
@@ -24,16 +24,6 @@ from functools import cached_property
24
25
from vllm.parser.engine.events import EventType
26
27
-STRUCTURAL_DROP_TOKENS: frozenset[str] = frozenset(
28
- {
29
- "<eos>",
30
- "<bos>",
31
- "<pad>",
32
- "<unk>",
33
- "<mask>",
34
- }
35
-)
36
-
27
28
class ParserState(Enum):
29
CONTENT = auto()
@@ -90,6 +80,9 @@ class ParserEngineConfig:
80
81
arg_structural_chars: frozenset[str] | None = None
82
83
+ # Special tokens exempt from auto-drop but not state-machine terminals.
84
+ preserve_tokens: frozenset[str] = field(default_factory=frozenset)
85
+
86
# Prevents trailing-whitespace accumulation across multi-turn conversations.
87
strip_trailing_reasoning_whitespace: bool = True
88
@@ -102,8 +95,6 @@ class ParserEngineConfig:
95
# Reject tool calls whose names are absent from the request tools.
96
validate_tool_names: bool = False
97
105
- drop_tokens: frozenset[str] = field(default_factory=frozenset)
106
-
98
@cached_property
99
def terminal_defs(self):
100
from vllm.parser.engine.incremental_lexer import terminals_from_literals
vllm/parser/engine/streaming_parser_engine.py
+82
-17
@@ -6,15 +6,17 @@ incremental lexing, and state-machine-driven semantic event emission."""
6
from __future__ import annotations
7
8
from collections.abc import Sequence
9
+from dataclasses import dataclass
10
11
from vllm.parser.engine.events import EventType, SemanticEvent
12
from vllm.parser.engine.incremental_lexer import (
13
CONTENT_TERMINAL,
14
IncrementalLexer,
15
+ LexerShape,
16
LexToken,
17
+ TerminalDef,
18
)
19
from vllm.parser.engine.parser_engine_config import (
17
- STRUCTURAL_DROP_TOKENS,
20
ParserEngineConfig,
21
ParserState,
22
Transition,
@@ -26,6 +28,64 @@ from vllm.parser.engine.token_id_scanner import (
28
TokenIDScanner,
29
)
30
31
+DROP_TERMINAL = "__DROP__"
32
+
33
+
34
+@dataclass(slots=True)
35
+class _DropInfo:
36
+ lexer_shape: LexerShape
37
+ extra_token_ids: dict[int, str]
38
+
39
+
40
+def _build_drop_info(
41
+ config: ParserEngineConfig,
42
+ tokenizer,
43
+) -> _DropInfo | None:
44
+ try:
45
+ special_tokens: list[str] = list(tokenizer.all_special_tokens)
46
+ special_ids: list[int] = list(tokenizer.all_special_ids)
47
+ except (AttributeError, NotImplementedError):
48
+ return None
49
+
50
+ if not special_tokens:
51
+ return None
52
+
53
+ configured_texts = (
54
+ set(config.token_id_terminals.values())
55
+ | set(config.terminals.values())
56
+ | config.preserve_tokens
57
+ )
58
+
59
+ extra_token_ids: dict[int, str] = {}
60
+ drop_texts: set[str] = set()
61
+ for text, tid in zip(special_tokens, special_ids):
62
+ if text not in configured_texts:
63
+ extra_token_ids[tid] = DROP_TERMINAL
64
+ drop_texts.add(text)
65
+
66
+ if not drop_texts:
67
+ return None
68
+
69
+ import regex as re
70
+
71
+ drop_terminal_defs = [
72
+ TerminalDef(
73
+ name=DROP_TERMINAL,
74
+ pattern=re.compile(re.escape(text)),
75
+ is_literal=True,
76
+ literal=text,
77
+ )
78
+ for text in drop_texts
79
+ ]
80
+
81
+ all_terminal_defs = list(config.terminal_defs) + drop_terminal_defs
82
+ lexer_shape = LexerShape(all_terminal_defs)
83
+
84
+ return _DropInfo(
85
+ lexer_shape=lexer_shape,
86
+ extra_token_ids=extra_token_ids,
87
+ )
88
+
89
90
class StreamingParserEngine:
91
"""Consumes ``(delta_text, delta_token_ids)`` pairs and produces a
@@ -60,7 +120,6 @@ class StreamingParserEngine:
120
self.config = config
121
122
resolved_token_ids: dict[int, str] = {}
63
- drop_token_ids: set[int] = set()
123
if tokenizer is not None:
124
if vocab is None:
125
vocab = tokenizer.get_vocab()
@@ -69,32 +128,29 @@ class StreamingParserEngine:
128
tid = vocab.get(token_text)
129
if tid is not None:
130
resolved_token_ids[tid] = terminal_name
72
- all_drop = config.drop_tokens | STRUCTURAL_DROP_TOKENS
73
- for token_text in all_drop:
74
- tid = vocab.get(token_text)
75
- if tid is not None:
76
- drop_token_ids.add(tid)
77
- for attr in ("eos_token_id", "bos_token_id", "pad_token_id"):
78
- tid = getattr(tokenizer, attr, None)
79
- if tid is not None:
80
- drop_token_ids.add(tid)
131
+
132
+ drop_info: _DropInfo | None = None
133
+ if tokenizer is not None:
134
+ drop_info = _build_drop_info(config, tokenizer)
135
+
136
+ lexer_shape = config.lexer_shape
137
+ if drop_info is not None:
138
+ resolved_token_ids.update(drop_info.extra_token_ids)
139
+ lexer_shape = drop_info.lexer_shape
140
141
self._resolved_token_ids = resolved_token_ids
83
- self._drop_token_ids = drop_token_ids
142
+ self._has_drops = drop_info is not None
143
144
self._scanner = TokenIDScanner(
145
resolved_token_ids,
146
tokenizer,
88
- drop_token_ids,
147
)
148
149
self._token_id_terminal_names: frozenset[str] = frozenset(
150
resolved_token_ids.values()
151
)
152
95
- self._lexer = IncrementalLexer(
96
- config.lexer_shape, content_terminal=CONTENT_TERMINAL
97
- )
153
+ self._lexer = IncrementalLexer(lexer_shape, content_terminal=CONTENT_TERMINAL)
154
155
self._tool_terminals: frozenset[str] = frozenset(
156
terminal
@@ -150,7 +206,7 @@ class StreamingParserEngine:
206
):
207
has_special = False
208
for tid in delta_token_ids:
153
- if tid in self._resolved_token_ids or tid in self._drop_token_ids:
209
+ if tid in self._resolved_token_ids:
210
has_special = True
211
break
212
if not has_special:
@@ -247,6 +303,15 @@ class StreamingParserEngine:
303
transition = self.config.transitions.get(key)
304
305
if transition is None:
306
+ if (
307
+ self._has_drops
308
+ and terminal == DROP_TERMINAL
309
+ # Preserve drop tokens when skip_tool_parsing is active so
310
+ # the reasoning pass doesn't silently remove tokens that a
311
+ # later tool-call pass might need to see.
312
+ and not self.skip_tool_parsing
313
+ ):
314
+ return []
315
return self._emit_for_state(value)
316
317
if self.skip_tool_parsing and terminal in self._tool_terminals:
vllm/parser/engine/token_id_scanner.py
+4
-27
@@ -42,12 +42,10 @@ class TokenIDScanner:
42
self,
43
token_id_to_terminal: dict[int, str],
44
tokenizer,
45
- drop_token_ids: set[int] | None = None,
45
) -> None:
46
self.token_id_to_terminal = token_id_to_terminal
47
self.tokenizer = tokenizer
48
self._token_text_cache: dict[int, str] = {}
50
- self._drop_token_ids = drop_token_ids or set()
49
self._deferred_terminals: list[PreLexedTerminal] = []
50
self._deferred_post_text: str = ""
51
@@ -74,22 +72,19 @@ class TokenIDScanner:
72
if self._deferred_terminals:
73
prefix_items, effective_text = self._resolve_deferred(delta_text)
74
77
- if not self.token_id_to_terminal and not self._drop_token_ids:
75
+ if not self.token_id_to_terminal:
76
if effective_text:
77
prefix_items.append(TextChunk(effective_text))
78
return prefix_items
79
80
has_special = False
83
- has_drop = False
81
token_id_to_terminal = self.token_id_to_terminal
85
- drop_token_ids = self._drop_token_ids
82
for tid in delta_token_ids:
83
if tid in token_id_to_terminal:
84
has_special = True
89
- if tid in drop_token_ids:
90
- has_drop = True
85
+ break
86
92
- if not has_special and not has_drop:
87
+ if not has_special:
88
if effective_text:
89
if not prefix_items:
90
return [TextChunk(effective_text)]
@@ -102,8 +97,6 @@ class TokenIDScanner:
97
text_accum: list[str] = []
98
99
for idx, tid in enumerate(delta_token_ids):
105
- if tid in self._drop_token_ids:
106
- continue
100
terminal = self.token_id_to_terminal.get(tid)
101
if terminal is not None:
102
if text_accum:
@@ -121,23 +114,7 @@ class TokenIDScanner:
114
results.append(TextChunk(joined))
115
116
if effective_text:
124
- if has_drop:
125
- clean_delta = effective_text
126
- for idx, tid in enumerate(delta_token_ids):
127
- if tid in self._drop_token_ids:
128
- dropped = token_texts[idx]
129
- pos = clean_delta.find(dropped)
130
- if pos >= 0:
131
- clean_delta = (
132
- clean_delta[:pos] + clean_delta[pos + len(dropped) :]
133
- )
134
- if clean_delta:
135
- if results:
136
- results = self._recover_holdback_text(clean_delta, results)
137
- else:
138
- results = [TextChunk(clean_delta)]
139
- else:
140
- results = self._recover_holdback_text(effective_text, results)
117
+ results = self._recover_holdback_text(effective_text, results)
118
else:
119
# No detokenizer text to validate against — individually-decoded
120
# TextChunks are unreliable (context-dependent decoding).
vllm/parser/gemma4.py
+1
-58
@@ -35,34 +35,6 @@ if TYPE_CHECKING:
35
from vllm.tokenizers import TokenizerLike
36
from vllm.tool_parsers.abstract_tool_parser import Tool
37
38
-# Tokens the model generates that must not leak into response content.
39
-_GEMMA4_MODEL_DROP_TOKENS: set[str] = {
40
- # Turn boundaries
41
- "<|turn>",
42
- "<turn|>",
43
- # Channel / reasoning
44
- "<|channel>",
45
- "<channel|>",
46
- # Tool protocol tokens
47
- "<|tool>",
48
- "<tool|>",
49
- "<|tool_call>",
50
- "<tool_call|>",
51
- "<|tool_response>",
52
- "<tool_response|>",
53
- '<|"|>',
54
- # Thinking
55
- "<|think|>",
56
- # Multi-modal (defensive — not expected during text completion)
57
- "<|image>",
58
- "<|image|>",
59
- "<image|>",
60
- "<|audio>",
61
- "<|audio|>",
62
- "<audio|>",
63
- "<|video|>",
64
-}
65
-
38
CHANNEL_START = "<|channel>"
39
CHANNEL_END = "<channel|>"
40
TOOL_CALL_START = "<|tool_call>"
@@ -322,14 +294,6 @@ def _gemma4_arg_converter(raw_args: str, partial: bool) -> str:
294
295
@functools.cache
296
def gemma4_config() -> ParserEngineConfig:
325
- used_tokens = {
326
- CHANNEL_START,
327
- CHANNEL_END,
328
- TOOL_CALL_START,
329
- TOOL_CALL_END,
330
- '<|"|>',
331
- }
332
-
297
return ParserEngineConfig(
298
name="gemma4",
299
initial_state=ParserState.CONTENT,
@@ -412,7 +376,7 @@ def gemma4_config() -> ParserEngineConfig:
376
arg_converter=_gemma4_arg_converter,
377
tool_args_json=False,
378
arg_structural_chars=frozenset(",:{}[]<"),
415
- drop_tokens=frozenset(_GEMMA4_MODEL_DROP_TOKENS - used_tokens),
379
+ preserve_tokens=frozenset({STRING_DELIM}),
380
)
381
382
@@ -451,27 +415,6 @@ class Gemma4Parser(ParserEngine):
415
self._prefix_stripped: bool = False
416
self._is_first_feed: bool = True
417
454
- def adjust_request(
455
- self,
456
- request: ChatCompletionRequest | ResponsesRequest,
457
- ) -> ChatCompletionRequest | ResponsesRequest:
458
- """Keep special tokens when thinking or tool calls need them.
459
-
460
- ``skip_special_tokens`` must stay ``False`` when there is something to
461
- preserve: reasoning channel tokens (thinking enabled) or tool-call
462
- delimiters (tools active). Otherwise keep the default so stray
463
- delimiters do not leak into content (e.g. ``tool_choice="none"`` with
464
- thinking disabled).
465
- """
466
- request = super().adjust_request(request)
467
- chat_template_kwargs = getattr(request, "chat_template_kwargs", None) or {}
468
- enable_thinking = chat_template_kwargs.get("enable_thinking", True)
469
- has_tools = bool(getattr(request, "tools", None))
470
- tools_active = has_tools and request.tool_choice != "none"
471
- if not enable_thinking and not tools_active:
472
- request.skip_special_tokens = True
473
- return request
474
-
418
def _reset(self, initial_state=None) -> None:
419
super()._reset(initial_state=initial_state)
420
self._reasoning_text = ""