[Refactor] StructuredOutputManager x Speculative Decoding Refactor (#48200)
yzong-rh committed
Sep 15, 2026 at 09:48 UTC
e6960af33b379d502f409e3e2241bbf2b2c2f68d
12 files changed
+595
-1191
tests/parser/engine/test_deepseek_v4.py
+1
-1
@@ -574,7 +574,7 @@ class TestImplicitReasoningEnd:
574
# the text lexer.
575
assert thinking_parser.reasoning_end_token_ids == {_THINK_END_ID}
576
assert thinking_parser.find_reasoning_end_offset([7, _THINK_END_ID, 8]) == 1
577
- assert thinking_parser.find_reasoning_end_offset([7, 8]) is None
577
+ assert thinking_parser.find_reasoning_end_offset([7, 8]) == 2
578
579
def test_streaming_reasoning_implicit_end(self, thinking_parser):
580
chunks = [
tests/parser/engine/test_parser_engine.py
+2
-2
@@ -185,8 +185,8 @@ class TestReasoningEndTokenIds:
185
def test_find_reasoning_end_offset_returns_first_match(self):
186
engine = _make_engine()
187
assert engine.find_reasoning_end_offset([5, 201, 6, 201]) == 1
188
- assert engine.find_reasoning_end_offset([5, 6]) is None
189
- assert engine.find_reasoning_end_offset([]) is None
188
+ assert engine.find_reasoning_end_offset([5, 6]) == 2
189
+ assert engine.find_reasoning_end_offset([]) == 0
190
# Rejected-draft placeholders never match.
191
assert engine.find_reasoning_end_offset([-1, 201]) == 1
192
tests/v1/core/test_async_scheduler.py
+1
-5
@@ -334,7 +334,6 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
334
request = create_requests(num_requests=1, num_tokens=1)[0]
335
request.structured_output_request = Mock()
336
request.structured_output_request.grammar = Mock(spec=StructuredOutputGrammar)
337
- request.structured_output_request.grammar.accept_tokens.return_value = False
337
request.status = RequestStatus.RUNNING
338
request.num_computed_tokens = request.num_tokens
339
request.num_output_placeholders = 1
@@ -343,10 +342,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
342
scheduler.connector = None
343
scheduler.ec_connector = None
344
scheduler.structured_output_manager = Mock()
346
- scheduler.structured_output_manager.should_advance.return_value = True
347
- scheduler.structured_output_manager.trim_reasoning_for_advance.side_effect = (
348
- lambda request, new_token_ids: new_token_ids
349
- )
345
+ scheduler.structured_output_manager.accept_tokens.return_value = False
346
scheduler.requests = {request.request_id: request}
347
scheduler.running = [request]
348
scheduler.waiting = Mock()
tests/v1/core/test_scheduler.py
+11
-17
@@ -3674,7 +3674,6 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3674
)
3675
request.structured_output_request = Mock()
3676
request.structured_output_request.grammar = Mock(spec=StructuredOutputGrammar)
3677
- request.structured_output_request.grammar.accept_tokens.return_value = False
3677
request.status = RequestStatus.RUNNING
3678
request.num_computed_tokens = request.num_tokens
3679
@@ -3682,10 +3681,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3681
scheduler.connector = None
3682
scheduler.ec_connector = None
3683
scheduler.structured_output_manager = Mock()
3685
- scheduler.structured_output_manager.should_advance.return_value = True
3686
- scheduler.structured_output_manager.trim_reasoning_for_advance.side_effect = (
3687
- lambda request, new_token_ids: new_token_ids
3688
- )
3684
+ scheduler.structured_output_manager.accept_tokens.return_value = False
3685
scheduler.requests = {request.request_id: request}
3686
scheduler.running = [request]
3687
scheduler.waiting = Mock()
@@ -3734,8 +3730,8 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3730
)
3731
engine_core_outputs = scheduler.update_from_output(output, model_runner_output)
3732
3737
- request.structured_output_request.grammar.accept_tokens.assert_called_once_with(
3738
- request.request_id, [123]
3733
+ scheduler.structured_output_manager.accept_tokens.assert_called_once_with(
3734
+ request, [123]
3735
)
3736
assert request.resumable is False
3737
assert request.status == RequestStatus.FINISHED_ERROR
@@ -6573,16 +6569,15 @@ def _decode_ready_request(scheduler):
6569
return request
6570
6571
6576
-def test_update_draft_token_ids_strips_ngram_padding(monkeypatch):
6577
- """ngram_gpu pads unfilled draft slots with -1; update_draft_token_ids must
6572
+def test_update_draft_token_ids_strips_ngram_padding():
6573
+ """ngram_gpu pads unfilled draft slots with -1; manager validation must
6574
strip them before grammar.validate_tokens (which otherwise raises)."""
6575
scheduler = create_scheduler(num_speculative_tokens=4)
6576
request = _decode_ready_request(scheduler)
6577
6578
grammar = _RecordingGrammar()
6583
- request.structured_output_request = SimpleNamespace(grammar=grammar)
6584
- monkeypatch.setattr(
6585
- scheduler.structured_output_manager, "should_advance", lambda req: True
6579
+ request.structured_output_request = SimpleNamespace(
6580
+ grammar=grammar, reasoning_ended=True
6581
)
6582
6583
scheduler.update_draft_token_ids(
@@ -6593,16 +6588,15 @@ def test_update_draft_token_ids_strips_ngram_padding(monkeypatch):
6588
assert request.spec_token_ids == [10, 11]
6589
6590
6596
-def test_update_draft_token_ids_in_output_strips_padding(monkeypatch):
6591
+def test_update_draft_token_ids_in_output_strips_padding():
6592
"""Same guard on the output path; the -1 pad-back for the rejected count
6598
- is preserved (only the input to validate_tokens is stripped)."""
6593
+ is preserved (only the input to manager validation is stripped)."""
6594
scheduler = create_scheduler(num_speculative_tokens=4)
6595
request = _decode_ready_request(scheduler)
6596
6597
grammar = _RecordingGrammar()
6603
- request.structured_output_request = SimpleNamespace(grammar=grammar)
6604
- monkeypatch.setattr(
6605
- scheduler.structured_output_manager, "should_advance", lambda req: True
6598
+ request.structured_output_request = SimpleNamespace(
6599
+ grammar=grammar, reasoning_ended=True
6600
)
6601
6602
scheduler_output = SimpleNamespace(
tests/v1/spec_decode/test_mtp_structured_output.py
+2
-357
@@ -1,6 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""grammar_bitmask under spec-decode draft padding (#44006)."""
3
+"""Backend and engine-reasoner checks that are not manager-flow tests."""
4
5
import pytest
6
from transformers import AutoTokenizer
@@ -49,221 +49,6 @@ def _make_manager_and_request(backend: str, prompt_str: str = '{"a": "b"}'):
49
return tokenizer, manager, request, prompt
50
51
52
-@pytest.mark.parametrize("backend", ["xgrammar", "guidance"])
53
-def test_bitmask_with_padded_invalid_drafts(backend):
54
- """Bitmask handles -1 padded drafts and returns N+1 rows."""
55
- tokenizer, manager, request, prompt = _make_manager_and_request(
56
- backend, prompt_str='{"a"'
57
- )
58
- grammar = request.structured_output_request.grammar
59
-
60
- assert grammar.accept_tokens(request.request_id, prompt)
61
-
62
- valid_drafts = [tokenizer.encode(":")[0], tokenizer.encode(' "')[0]]
63
- padded = valid_drafts + [-1, -1]
64
-
65
- bitmask = manager.grammar_bitmask(
66
- requests={request.request_id: request},
67
- structured_output_request_ids=[request.request_id],
68
- scheduled_spec_decode_tokens={request.request_id: padded},
69
- )
70
-
71
- assert bitmask is not None
72
- assert bitmask.shape[0] == len(padded) + 1
73
- assert not grammar.is_terminated()
74
-
75
-
76
-@pytest.mark.parametrize("backend", ["xgrammar", "guidance"])
77
-def test_bitmask_when_grammar_terminates_mid_window(backend):
78
- """Drafts following an EOS that terminates the grammar are a no-op."""
79
- tokenizer, manager, request, prompt = _make_manager_and_request(backend)
80
- grammar = request.structured_output_request.grammar
81
-
82
- assert grammar.accept_tokens(request.request_id, prompt)
83
- eos = tokenizer.eos_token_id
84
- drafts = [eos] + [tokenizer.encode(" ")[0]] * (NUM_SPEC_TOKENS - 1)
85
-
86
- bitmask = manager.grammar_bitmask(
87
- requests={request.request_id: request},
88
- structured_output_request_ids=[request.request_id],
89
- scheduled_spec_decode_tokens={request.request_id: drafts},
90
- )
91
-
92
- assert bitmask is not None
93
- assert bitmask.shape[0] == NUM_SPEC_TOKENS + 1
94
- assert not grammar.is_terminated()
95
-
96
-
97
-@pytest.mark.parametrize("backend", ["xgrammar", "guidance"])
98
-def test_bitmask_idempotent_across_calls(backend):
99
- """Repeated calls with the same input return the same bitmask."""
100
- tokenizer, manager, request, prompt = _make_manager_and_request(
101
- backend, prompt_str='{"a"'
102
- )
103
- grammar = request.structured_output_request.grammar
104
-
105
- assert grammar.accept_tokens(request.request_id, prompt)
106
-
107
- drafts = [tokenizer.encode(":")[0], -1, -1, -1]
108
-
109
- first = manager.grammar_bitmask(
110
- requests={request.request_id: request},
111
- structured_output_request_ids=[request.request_id],
112
- scheduled_spec_decode_tokens={request.request_id: drafts},
113
- )
114
- second = manager.grammar_bitmask(
115
- requests={request.request_id: request},
116
- structured_output_request_ids=[request.request_id],
117
- scheduled_spec_decode_tokens={request.request_id: drafts},
118
- )
119
-
120
- assert first is not None and second is not None
121
- assert (first == second).all()
122
- assert not grammar.is_terminated()
123
-
124
-
125
-@pytest.mark.parametrize("backend", ["xgrammar", "guidance"])
126
-def test_bonus_position_constrained_after_invalid_drafts(backend):
127
- """Regression for #44006: bonus row stays constrained after -1 padding."""
128
- tokenizer, manager, request, prompt = _make_manager_and_request(
129
- backend, prompt_str='{"a"'
130
- )
131
- grammar = request.structured_output_request.grammar
132
-
133
- assert grammar.accept_tokens(request.request_id, prompt)
134
-
135
- valid = tokenizer.encode(":")[0]
136
- drafts = [valid, -1, -1, -1]
137
- bitmask = manager.grammar_bitmask(
138
- requests={request.request_id: request},
139
- structured_output_request_ids=[request.request_id],
140
- scheduled_spec_decode_tokens={request.request_id: drafts},
141
- )
142
- assert bitmask is not None
143
- assert bitmask.shape[0] == len(drafts) + 1
144
-
145
- assert not (bitmask[-1] == -1).all()
146
- assert not grammar.is_terminated()
147
-
148
-
149
-@pytest.mark.parametrize("backend", ["xgrammar", "guidance"])
150
-def test_bitmask_constrained_when_reasoning_ends_midwindow(backend):
151
- """Drafts after a mid-window reasoning-end marker stay constrained."""
152
- tokenizer, manager, request, prompt = _make_manager_and_request(backend)
153
- grammar = request.structured_output_request.grammar
154
-
155
- assert grammar.accept_tokens(request.request_id, prompt)
156
-
157
- marker = tokenizer.encode("\n")[0]
158
-
159
- class StubReasoner:
160
- def __init__(self, *_, **__):
161
- self.end_token_id = marker
162
-
163
- def is_reasoning_end(self, input_ids):
164
- return marker in list(input_ids)
165
-
166
- def is_reasoning_end_streaming(self, input_ids, delta_ids):
167
- return marker in list(delta_ids)
168
-
169
- manager.reasoner_cls = StubReasoner
170
- request.structured_output_request.reasoner = StubReasoner()
171
- request.structured_output_request.reasoning_ended = False
172
-
173
- pre = tokenizer.encode(" ")[0]
174
- post = tokenizer.encode(",")[0]
175
- drafts = [pre, marker, post]
176
-
177
- bitmask = manager.grammar_bitmask(
178
- requests={request.request_id: request},
179
- structured_output_request_ids=[request.request_id],
180
- scheduled_spec_decode_tokens={request.request_id: drafts},
181
- )
182
-
183
- assert bitmask is not None
184
- assert bitmask.shape[0] == len(drafts) + 1
185
- assert (bitmask[0] == -1).all()
186
- assert (bitmask[1] == -1).all()
187
- assert not (bitmask[2] == -1).all()
188
- assert not (bitmask[-1] == -1).all()
189
- assert not grammar.is_terminated()
190
-
191
-
192
-@pytest.mark.parametrize("backend", ["xgrammar", "guidance"])
193
-def test_bitmask_post_reasoning_end_drafts_skip_grammar_advance(backend, caplog):
194
- """Post-marker drafts predate the bitmask and may be grammar-invalid;
195
- grammar_bitmask must skip the grammar advance instead of asserting.
196
- """
197
- tokenizer, manager, request, prompt = _make_manager_and_request(
198
- backend, prompt_str="{"
199
- )
200
- grammar = request.structured_output_request.grammar
201
-
202
- assert grammar.accept_tokens(request.request_id, prompt)
203
- assert not grammar.is_terminated()
204
-
205
- marker = tokenizer.encode("\n")[0]
206
-
207
- class StubReasoner:
208
- def __init__(self, *_, **__):
209
- self.end_token_id = marker
210
-
211
- def is_reasoning_end(self, input_ids):
212
- return marker in list(input_ids)
213
-
214
- def is_reasoning_end_streaming(self, input_ids, delta_ids):
215
- return marker in list(delta_ids)
216
-
217
- manager.reasoner_cls = StubReasoner
218
- request.structured_output_request.reasoner = StubReasoner()
219
- request.structured_output_request.reasoning_ended = False
220
-
221
- pre = tokenizer.encode(" ")[0]
222
- # A token that the JSON grammar would reject as the first post-marker
223
- # token; without the fix grammar.accept_tokens fires the assertion.
224
- invalid_post = tokenizer.encode("z")[0]
225
- drafts = [pre, marker, invalid_post]
226
-
227
- bitmask = manager.grammar_bitmask(
228
- requests={request.request_id: request},
229
- structured_output_request_ids=[request.request_id],
230
- scheduled_spec_decode_tokens={request.request_id: drafts},
231
- )
232
-
233
- assert bitmask is not None
234
- assert bitmask.shape[0] == len(drafts) + 1
235
- # Post-marker position is still bitmask-constrained.
236
- assert not (bitmask[2] == -1).all()
237
- # Grammar must not have advanced through the unvalidated draft.
238
- assert not grammar.is_terminated()
239
- assert "Failed to advance FSM" not in caplog.text
240
-
241
-
242
-@pytest.mark.parametrize("backend", ["xgrammar", "guidance"])
243
-def test_validate_tokens_then_bitmask_round_trip(backend):
244
- """validate_tokens -> pad with -1 -> grammar_bitmask must not assert."""
245
- tokenizer, manager, request, prompt = _make_manager_and_request(backend)
246
- grammar = request.structured_output_request.grammar
247
-
248
- assert grammar.accept_tokens(request.request_id, prompt)
249
-
250
- raw_drafts = [tokenizer.encode(",")[0], 99999, 12345, 67890]
251
- accepted = grammar.validate_tokens(raw_drafts)
252
- assert len(accepted) <= len(raw_drafts)
253
-
254
- padded = accepted + [-1] * (len(raw_drafts) - len(accepted))
255
- assert len(padded) == len(raw_drafts)
256
-
257
- bitmask = manager.grammar_bitmask(
258
- requests={request.request_id: request},
259
- structured_output_request_ids=[request.request_id],
260
- scheduled_spec_decode_tokens={request.request_id: padded},
261
- )
262
- assert bitmask is not None
263
- assert bitmask.shape[0] == len(padded) + 1
264
- assert not grammar.is_terminated()
265
-
266
-
52
def test_xgrammar_accept_tokens_stops_at_termination(capfd):
53
"""Tokens after a terminating EOS do not reach the matcher."""
54
tokenizer, _, request, prompt = _make_manager_and_request("xgrammar")
@@ -302,7 +87,6 @@ def test_xgrammar_validate_tokens_stops_at_termination(capfd):
87
88
assert grammar.validate_tokens([eos, trailing]) == [eos]
89
assert "trying to accept new token" not in capfd.readouterr().err
305
- # Check matcher state directly to verify validation rolled it back.
90
assert not grammar.matcher.is_terminated()
91
92
assert grammar.accept_tokens(request.request_id, [eos])
@@ -312,87 +96,6 @@ def test_xgrammar_validate_tokens_stops_at_termination(capfd):
96
assert "trying to accept new token" not in capfd.readouterr().err
97
98
315
-class _MarkerReasoner:
316
- """Stub reasoner whose reasoning-end marker is a single fixed token."""
317
-
318
- def __init__(self, marker: int):
319
- self.marker = marker
320
-
321
- def is_reasoning_end(self, input_ids):
322
- return self.marker in list(input_ids)
323
-
324
- def is_reasoning_end_streaming(self, input_ids, delta_ids):
325
- return self.marker in list(delta_ids)
326
-
327
-
328
-def _setup_boundary_request(backend: str):
329
- """Request with a structural-tag key and reasoning not yet ended."""
330
- from vllm.v1.structured_output.backend_types import StructuredOutputOptions
331
-
332
- tokenizer, manager, request, prompt = _make_manager_and_request(backend)
333
- marker = tokenizer.encode("\n")[0]
334
- structured_req = request.structured_output_request
335
- # The grammar itself is JSON (cheap to build); only the key kind matters
336
- # for the should_advance structural-tag branch, so pre-seed the cached
337
- # property.
338
- structured_req.__dict__["structured_output_key"] = (
339
- StructuredOutputOptions.STRUCTURAL_TAG,
340
- "",
341
- )
342
- manager.reasoner_cls = _MarkerReasoner
343
- structured_req.reasoner = _MarkerReasoner(marker)
344
- structured_req.reasoning_ended = False
345
- return tokenizer, manager, request, prompt, marker
346
-
347
-
348
-def test_should_advance_records_reasoning_end_index():
349
- """Regression for #44006 on post-#42452 main: the boundary step must
350
- record where reasoning ends so the scheduler can trim before advancing.
351
- """
352
- tokenizer, manager, request, prompt, marker = _setup_boundary_request("xgrammar")
353
- structured_req = request.structured_output_request
354
-
355
- pre = tokenizer.encode(" ")[0]
356
- post = tokenizer.encode("{")[0]
357
- request.append_output_token_ids([pre, marker, post])
358
-
359
- assert manager.should_advance(request)
360
- assert structured_req.reasoning_ended
361
- # Marker sits at absolute index len(prompt) + 1.
362
- assert structured_req.reasoning_end_token_index == len(prompt) + 1
363
-
364
-
365
-def test_trim_reasoning_for_advance():
366
- """trim drops the marker and everything before it; later steps and
367
- requests without a recorded boundary pass through unchanged.
368
- """
369
- tokenizer, manager, request, prompt, marker = _setup_boundary_request("xgrammar")
370
- structured_req = request.structured_output_request
371
-
372
- pre = tokenizer.encode(" ")[0]
373
- post = tokenizer.encode("{")[0]
374
-
375
- # No boundary recorded yet: pass-through.
376
- assert manager.trim_reasoning_for_advance(request, [pre]) == [pre]
377
-
378
- # Boundary step: marker mid-step keeps only the suffix.
379
- step_tokens = [pre, marker, post]
380
- request.append_output_token_ids(step_tokens)
381
- assert manager.should_advance(request)
382
- assert manager.trim_reasoning_for_advance(request, step_tokens) == [post]
383
-
384
- # Boundary step variant: marker last (the #44006 crash shape
385
- # [198, </think>]) trims to empty -> scheduler skips accept_tokens.
386
- structured_req.reasoning_end_token_index = len(request.all_token_ids) - 1
387
- assert manager.trim_reasoning_for_advance(request, step_tokens) == []
388
-
389
- # Later steps: tokens are past the boundary, returned unchanged.
390
- structured_req.reasoning_end_token_index = len(prompt) + 1
391
- next_step = [post, post]
392
- request.append_output_token_ids(next_step)
393
- assert manager.trim_reasoning_for_advance(request, next_step) == next_step
394
-
395
-
99
class _EngineReasonerStub(ParserEngineReasoningAdapter):
100
"""Adapter-typed reasoner with a fixed end-token set and no real engine."""
101
@@ -409,7 +112,7 @@ class _EngineReasonerStub(ParserEngineReasoningAdapter):
112
for offset, token in enumerate(token_ids):
113
if token in self._end_token_ids:
114
return offset
412
- return None
115
+ return len(token_ids)
116
117
def is_reasoning_end(self, input_ids):
118
return any(token in self._end_token_ids for token in input_ids)
@@ -449,61 +152,3 @@ def test_bitmask_engine_reasoner_ends_midwindow_with_padding(backend):
152
assert not (bitmask[2] == -1).all()
153
assert reasoner.windows == [[pre, marker, post]]
154
assert not grammar.is_terminated()
452
-
453
-
454
-@pytest.mark.parametrize("backend", ["xgrammar", "guidance"])
455
-def test_bitmask_legacy_order_sensitive_predicate_probed_per_token(backend):
456
- """Legacy predicates are probed one draft at a time, never on the whole
457
- window. A predicate that reports which marker is *newest* (KimiK3) would
458
- otherwise answer False for a window that closes and then reopens
459
- reasoning, leaving the post-marker drafts and the bonus row
460
- unconstrained.
461
- """
462
- tokenizer, manager, request, prompt = _make_manager_and_request(backend)
463
- grammar = request.structured_output_request.grammar
464
-
465
- assert grammar.accept_tokens(request.request_id, prompt)
466
-
467
- close = tokenizer.encode("\n")[0]
468
- reopen = tokenizer.encode("#")[0]
469
- assert close != reopen
470
-
471
- class NewestMarkerReasoner:
472
- def __init__(self, *_, **__):
473
- pass
474
-
475
- def is_reasoning_end(self, input_ids):
476
- return self.is_reasoning_end_streaming(input_ids, input_ids)
477
-
478
- def is_reasoning_end_streaming(self, input_ids, delta_ids):
479
- for token in reversed(list(delta_ids)):
480
- if token == close:
481
- return True
482
- if token == reopen:
483
- return False
484
- return False
485
-
486
- manager.reasoner_cls = NewestMarkerReasoner
487
- request.structured_output_request.reasoner = NewestMarkerReasoner()
488
- request.structured_output_request.reasoning_ended = False
489
-
490
- pre = tokenizer.encode(" ")[0]
491
- post = tokenizer.encode(",")[0]
492
- drafts = [pre, close, post, reopen]
493
- # Sanity: the whole window says "reopened", so only per-token probing
494
- # can find the close marker.
495
- assert not NewestMarkerReasoner().is_reasoning_end_streaming([], drafts)
496
-
497
- bitmask = manager.grammar_bitmask(
498
- requests={request.request_id: request},
499
- structured_output_request_ids=[request.request_id],
500
- scheduled_spec_decode_tokens={request.request_id: drafts},
501
- )
502
-
503
- assert bitmask is not None
504
- assert bitmask.shape[0] == len(drafts) + 1
505
- assert (bitmask[0] == -1).all()
506
- assert (bitmask[1] == -1).all()
507
- assert not (bitmask[2] == -1).all()
508
- assert not (bitmask[-1] == -1).all()
509
- assert not grammar.is_terminated()
tests/v1/structured_output/test_reasoning_structured_output.py
deleted
-557
@@ -1,557 +0,0 @@
1
-# SPDX-License-Identifier: Apache-2.0
2
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-
4
-"""Unit tests for reasoning-aware structured output functionality (PR #25515)."""
5
-
6
-from collections.abc import Sequence
7
-from unittest.mock import Mock
8
-
9
-import pytest
10
-
11
-from vllm.config import ModelConfig, SchedulerConfig, VllmConfig
12
-from vllm.v1.request import Request
13
-from vllm.v1.structured_output import StructuredOutputManager
14
-from vllm.v1.structured_output.backend_types import StructuredOutputOptions
15
-
16
-
17
-class UntouchableTokenIds(Sequence[int]):
18
- """A token history that fails the test if anything reads it."""
19
-
20
- def __init__(self, length: int) -> None:
21
- self._length = length
22
-
23
- def __len__(self) -> int:
24
- return self._length
25
-
26
- def __getitem__(self, item):
27
- raise AssertionError("token history must not be read")
28
-
29
- def __iter__(self):
30
- raise AssertionError("token history must not be iterated")
31
-
32
-
33
-def make_qwen3_reasoner():
34
- """A real engine-based reasoning adapter over a tiny mock vocabulary."""
35
- from tests.parser.engine.conftest import make_mock_tokenizer
36
- from vllm.parser.engine.registered_adapters import Qwen3ParserReasoningAdapter
37
-
38
- tokenizer = make_mock_tokenizer(
39
- {"<think>": 200, "</think>": 201, "<tool_call>": 202, "</tool_call>": 203}
40
- )
41
- return Qwen3ParserReasoningAdapter(tokenizer)
42
-
43
-
44
-class MockReasoner:
45
- def __init__(self, tokenizer):
46
- self.is_reasoning_end = Mock(return_value=False)
47
- self.is_reasoning_end_streaming = Mock(return_value=False)
48
-
49
-
50
-class TestReasoningStructuredOutput:
51
- """Test reasoning-aware structured output functionality."""
52
-
53
- @pytest.fixture
54
- def mock_model_config(self):
55
- """Create a mock ModelConfig."""
56
- config = Mock(spec=ModelConfig)
57
- config.skip_tokenizer_init = True # Skip tokenizer init to avoid network calls
58
- config.get_vocab_size = Mock(return_value=50000)
59
- # Add missing runner_type attribute that tokenizer initialization expects
60
- config.runner_type = "generate"
61
- # Add other attributes that tokenizer initialization might need
62
- config.tokenizer = "test-tokenizer"
63
- config.tokenizer_mode = "auto"
64
- config.trust_remote_code = False
65
- config.tokenizer_revision = None
66
- return config
67
-
68
- @pytest.fixture
69
- def mock_scheduler_config(self):
70
- """Create a mock SchedulerConfig."""
71
- config = Mock(spec=SchedulerConfig)
72
- config.max_num_seqs = 128
73
- return config
74
-
75
- @pytest.fixture
76
- def mock_vllm_config(self, mock_model_config, mock_scheduler_config):
77
- """Create a mock VllmConfig."""
78
- config = Mock(spec=VllmConfig)
79
- config.model_config = mock_model_config
80
- config.scheduler_config = mock_scheduler_config
81
- config.structured_outputs_config = Mock()
82
- config.structured_outputs_config.reasoning_parser = None
83
- config.structured_outputs_config.enable_in_reasoning = False
84
- config.speculative_config = None
85
- return config
86
-
87
- @pytest.fixture
88
- def mock_request_with_structured_output(self):
89
- """Create a mock request with structured output."""
90
- request = Mock(spec=Request)
91
- request.structured_output_request = Mock()
92
- request.structured_output_request.reasoning_ended = None
93
- request.structured_output_request.grammar = Mock()
94
- request.structured_output_request.reasoning_parser_kwargs = None
95
- request.structured_output_request.reasoner = None
96
- request.structured_output_request.grammar.is_terminated = Mock(
97
- return_value=False
98
- )
99
- request.use_structured_output = True
100
- request.prompt_token_ids = [1, 2, 3, 4, 5]
101
- request.all_token_ids = [1, 2, 3, 4, 5, 6, 7, 8]
102
- request.num_computed_tokens = 5
103
- request.num_output_placeholders = 0
104
- request.request_id = "mock_req"
105
- return request
106
-
107
- @pytest.fixture
108
- def manager_with_reasoner(self, mock_vllm_config):
109
- manager = StructuredOutputManager(mock_vllm_config)
110
- manager.reasoner_cls = MockReasoner
111
- manager.tokenizer = Mock()
112
- return manager
113
-
114
- def test_should_fill_bitmask_with_enable_in_reasoning(
115
- self, manager_with_reasoner, mock_request_with_structured_output
116
- ):
117
- """Test should_fill_bitmask when enable_in_reasoning is True."""
118
- manager_with_reasoner.enable_in_reasoning = True
119
-
120
- # Should always return True when enable_in_reasoning is enabled
121
- result = manager_with_reasoner.should_fill_bitmask(
122
- mock_request_with_structured_output
123
- )
124
- assert result is True
125
- assert (
126
- mock_request_with_structured_output.structured_output_request.reasoner
127
- is None
128
- )
129
-
130
- def test_should_fill_bitmask_reasoning_already_ended(
131
- self,
132
- manager_with_reasoner,
133
- mock_request_with_structured_output,
134
- ):
135
- """An active grammar does not need a request-local reasoner."""
136
- structured_req = mock_request_with_structured_output.structured_output_request
137
- structured_req.reasoning_ended = True
138
-
139
- result = manager_with_reasoner.should_fill_bitmask(
140
- mock_request_with_structured_output
141
- )
142
-
143
- assert result is True
144
- assert structured_req.reasoner is None
145
-
146
- def test_grammar_bitmask_skips_reasoner_when_already_active(
147
- self,
148
- manager_with_reasoner,
149
- mock_request_with_structured_output,
150
- ):
151
- """Bitmask generation skips reasoning-boundary detection."""
152
- manager_with_reasoner.vllm_config.num_speculative_tokens = 1
153
- manager_with_reasoner._grammar_bitmask = Mock()
154
- manager_with_reasoner._grammar_bitmask.shape = (1, 1)
155
- expected_bitmask = Mock()
156
- manager_with_reasoner._grammar_bitmask.numpy.return_value = expected_bitmask
157
- manager_with_reasoner._fill_bitmasks = Mock()
158
-
159
- structured_req = mock_request_with_structured_output.structured_output_request
160
- structured_req.reasoning_ended = True
161
- request_id = mock_request_with_structured_output.request_id
162
-
163
- bitmask = manager_with_reasoner.grammar_bitmask(
164
- requests={request_id: mock_request_with_structured_output},
165
- structured_output_request_ids=[request_id],
166
- scheduled_spec_decode_tokens={},
167
- )
168
-
169
- assert bitmask is expected_bitmask
170
- assert structured_req.reasoner is None
171
-
172
- def test_should_fill_bitmask_without_enable_in_reasoning(
173
- self,
174
- manager_with_reasoner,
175
- mock_request_with_structured_output,
176
- ):
177
- """Test should_fill_bitmask when enable_in_reasoning is False."""
178
- # Keep enable_in_reasoning as False (default)
179
- config = manager_with_reasoner.vllm_config.structured_outputs_config
180
- assert config.enable_in_reasoning is False
181
-
182
- result = manager_with_reasoner.should_fill_bitmask(
183
- mock_request_with_structured_output
184
- )
185
-
186
- # Should set reasoning_ended and return its value
187
- assert (
188
- mock_request_with_structured_output.structured_output_request.reasoning_ended
189
- is False
190
- )
191
- assert result is False
192
-
193
- def test_should_fill_bitmask_no_reasoner(
194
- self, mock_vllm_config, mock_request_with_structured_output
195
- ):
196
- """Test should_fill_bitmask when no reasoner is configured."""
197
- manager = StructuredOutputManager(mock_vllm_config)
198
-
199
- result = manager.should_fill_bitmask(mock_request_with_structured_output)
200
-
201
- # Should default to True when no reasoner
202
- assert result is True
203
-
204
- def test_should_fill_bitmask_uses_request_reasoning_parser_kwargs(
205
- self, mock_vllm_config, mock_request_with_structured_output
206
- ):
207
- """Test request-level parser kwargs override the default reasoner."""
208
-
209
- class KwargReasoner:
210
- def __init__(self, tokenizer, chat_template_kwargs=None):
211
- self.chat_template_kwargs = chat_template_kwargs or {}
212
-
213
- def is_reasoning_end(self, input_ids):
214
- return not self.chat_template_kwargs.get("enable_thinking", False)
215
-
216
- manager = StructuredOutputManager(mock_vllm_config)
217
- manager.reasoner_cls = KwargReasoner
218
- manager.tokenizer = Mock()
219
-
220
- structured_req = mock_request_with_structured_output.structured_output_request
221
- structured_req.reasoning_parser_kwargs = {
222
- "chat_template_kwargs": {"enable_thinking": True}
223
- }
224
-
225
- result = manager.should_fill_bitmask(mock_request_with_structured_output)
226
-
227
- assert result is False
228
- assert (
229
- mock_request_with_structured_output.structured_output_request.reasoner
230
- is not None
231
- )
232
-
233
- def test_should_advance_with_enable_in_reasoning(
234
- self,
235
- manager_with_reasoner,
236
- mock_request_with_structured_output,
237
- ):
238
- """Test should_advance when enable_in_reasoning is True."""
239
- # Enable enable_in_reasoning
240
- manager_with_reasoner.enable_in_reasoning = True
241
-
242
- # Should always return True when enable_in_reasoning is enabled
243
- result = manager_with_reasoner.should_advance(
244
- mock_request_with_structured_output
245
- )
246
- assert result is True
247
- assert (
248
- mock_request_with_structured_output.structured_output_request.reasoner
249
- is None
250
- )
251
-
252
- def test_should_advance_reasoning_not_ended(
253
- self,
254
- manager_with_reasoner,
255
- mock_request_with_structured_output,
256
- ):
257
- """Test should_advance when reasoning has not ended."""
258
- # Set reasoning as not ended
259
- (
260
- mock_request_with_structured_output.structured_output_request
261
- ).reasoning_ended = False
262
-
263
- result = manager_with_reasoner.should_advance(
264
- mock_request_with_structured_output
265
- )
266
-
267
- # Should return False since reasoning hasn't ended
268
- assert result is False
269
-
270
- def test_should_advance_reasoning_just_ended(
271
- self,
272
- manager_with_reasoner,
273
- mock_request_with_structured_output,
274
- ):
275
- """Test should_advance when reasoning ends in current step."""
276
- # Set reasoning as not ended initially, but ends in this step
277
- (
278
- mock_request_with_structured_output.structured_output_request
279
- ).reasoning_ended = False
280
- reasoner = MockReasoner(tokenizer=Mock())
281
- reasoner.is_reasoning_end_streaming.return_value = True
282
- structured_req = mock_request_with_structured_output.structured_output_request
283
- structured_req.reasoner = reasoner
284
-
285
- result = manager_with_reasoner.should_advance(
286
- mock_request_with_structured_output
287
- )
288
-
289
- # The scheduler trims the reasoning prefix before advancing the grammar.
290
- assert (
291
- mock_request_with_structured_output.structured_output_request.reasoning_ended
292
- is True
293
- )
294
- assert result is True
295
-
296
- def test_should_advance_reasoning_already_ended(
297
- self,
298
- manager_with_reasoner,
299
- mock_request_with_structured_output,
300
- ):
301
- """Test should_advance when reasoning has already ended."""
302
- # Set reasoning as already ended
303
- (
304
- mock_request_with_structured_output.structured_output_request
305
- ).reasoning_ended = True
306
-
307
- result = manager_with_reasoner.should_advance(
308
- mock_request_with_structured_output
309
- )
310
-
311
- # Should return True since reasoning has ended
312
- assert result is True
313
- assert (
314
- mock_request_with_structured_output.structured_output_request.reasoner
315
- is None
316
- )
317
-
318
- def test_should_advance_uses_new_token_ids_when_provided(
319
- self,
320
- manager_with_reasoner,
321
- mock_request_with_structured_output,
322
- ):
323
- """Regression for #43388: when caller passes new_token_ids, the
324
- reasoner sees the exact multi-token delta rather than the
325
- placeholder-derived window.
326
- """
327
- structured_req = mock_request_with_structured_output.structured_output_request
328
- structured_req.reasoning_ended = False
329
-
330
- end_token_id = 248069
331
-
332
- reasoner = MockReasoner(tokenizer=Mock())
333
- # Detection mirrors the real Qwen3 parser: end token in the delta.
334
- reasoner.is_reasoning_end_streaming = Mock(
335
- side_effect=lambda input_ids, delta_ids: end_token_id in list(delta_ids)
336
- )
337
- structured_req.reasoner = reasoner
338
-
339
- # Scenario from #43388: async + spec decode K=4, 4 tokens accepted
340
- # but only 1 placeholder remains (some drafts were rejected).
341
- # The placeholder math would yield delta=[271] and miss </think>.
342
- # Passing new_token_ids must override that.
343
- new_token_ids = [9, 198, end_token_id, 271]
344
- mock_request_with_structured_output.all_token_ids = [
345
- 1,
346
- 2,
347
- 3,
348
- 4,
349
- 5,
350
- ] + new_token_ids
351
- mock_request_with_structured_output.num_computed_tokens = 9
352
- mock_request_with_structured_output.num_output_placeholders = 1
353
-
354
- result = manager_with_reasoner.should_advance(
355
- mock_request_with_structured_output,
356
- new_token_ids=new_token_ids,
357
- )
358
-
359
- # First call to is_reasoning_end_streaming was with the full
360
- # new_token_ids (not the truncated placeholder window).
361
- first_call = reasoner.is_reasoning_end_streaming.call_args_list[0]
362
- _, called_delta = first_call.args
363
- assert list(called_delta) == new_token_ids
364
-
365
- assert structured_req.reasoning_ended is True
366
- assert result is True
367
-
368
- def test_should_advance_without_new_token_ids_falls_back(
369
- self,
370
- manager_with_reasoner,
371
- mock_request_with_structured_output,
372
- ):
373
- """Backward compat: callers that don't pass new_token_ids keep
374
- the original placeholder-derived delta window.
375
- """
376
- structured_req = mock_request_with_structured_output.structured_output_request
377
- structured_req.reasoning_ended = False
378
- reasoner = MockReasoner(tokenizer=Mock())
379
- reasoner.is_reasoning_end_streaming.return_value = False
380
- structured_req.reasoner = reasoner
381
-
382
- mock_request_with_structured_output.all_token_ids = [1, 2, 3, 4, 5]
383
- mock_request_with_structured_output.num_computed_tokens = 5
384
- mock_request_with_structured_output.num_output_placeholders = 2
385
-
386
- result = manager_with_reasoner.should_advance(
387
- mock_request_with_structured_output
388
- )
389
-
390
- # placeholder window: start = 5 - 2 = 3, delta = [4, 5]
391
- _, called_delta = reasoner.is_reasoning_end_streaming.call_args[0]
392
- assert list(called_delta) == [4, 5]
393
- assert result is False
394
-
395
- def test_should_advance_whole_delta_fallback_pins_last_sequence_index(
396
- self,
397
- manager_with_reasoner,
398
- mock_request_with_structured_output,
399
- ):
400
- """When only the whole-delta predicate fires, the boundary is the
401
- last index of the sequence, even if the placeholder window
402
- overshoots it and the delta is empty.
403
- """
404
- structured_req = mock_request_with_structured_output.structured_output_request
405
- structured_req.reasoning_ended = False
406
- reasoner = MockReasoner(tokenizer=Mock())
407
- # Base-default shape: rescans the whole sequence, ignores the delta.
408
- reasoner.is_reasoning_end_streaming.return_value = True
409
- structured_req.reasoner = reasoner
410
-
411
- mock_request_with_structured_output.all_token_ids = [1, 2, 3, 4, 5]
412
- # placeholder window: start = 9 - 1 = 8, past the end -> delta = []
413
- mock_request_with_structured_output.num_computed_tokens = 9
414
- mock_request_with_structured_output.num_output_placeholders = 1
415
-
416
- assert manager_with_reasoner.should_advance(mock_request_with_structured_output)
417
- assert structured_req.reasoning_ended is True
418
- assert structured_req.reasoning_end_token_index == 4
419
-
420
- def test_should_advance_trims_reasoning_prefix_for_json(
421
- self,
422
- manager_with_reasoner,
423
- mock_request_with_structured_output,
424
- ):
425
- """JSON uses the common trim-then-advance path at the boundary."""
426
- structured_req = mock_request_with_structured_output.structured_output_request
427
- structured_req.reasoning_ended = False
428
- structured_req.structured_output_key = (
429
- StructuredOutputOptions.JSON_OBJECT,
430
- "{}",
431
- )
432
-
433
- marker = 248069
434
-
435
- class MarkerReasoner:
436
- def __init__(self, *_, **__):
437
- pass
438
-
439
- def is_reasoning_end_streaming(self, input_ids, delta_ids):
440
- return marker in list(delta_ids)
441
-
442
- structured_req.reasoner = MarkerReasoner()
443
-
444
- new_token_ids = [9, 198, marker, 271, 5005]
445
- mock_request_with_structured_output.all_token_ids = [1, 2, 3] + new_token_ids
446
-
447
- result = manager_with_reasoner.should_advance(
448
- mock_request_with_structured_output,
449
- new_token_ids=new_token_ids,
450
- )
451
-
452
- structured_req.grammar.accept_tokens.assert_not_called()
453
- assert structured_req.reasoning_ended is True
454
- assert result is True
455
- assert structured_req.reasoning_end_token_index == 5
456
- assert manager_with_reasoner.trim_reasoning_for_advance(
457
- mock_request_with_structured_output, new_token_ids
458
- ) == [271, 5005]
459
-
460
- def test_should_advance_engine_adapter_scans_only_the_delta(
461
- self,
462
- manager_with_reasoner,
463
- mock_request_with_structured_output,
464
- ):
465
- """Engine-based parsers locate the boundary from the delta alone."""
466
- structured_req = mock_request_with_structured_output.structured_output_request
467
- structured_req.reasoning_ended = False
468
- reasoner = make_qwen3_reasoner()
469
- assert reasoner.reasoning_end_token_ids == {201, 202}
470
- reasoner.is_reasoning_end_streaming = Mock(
471
- side_effect=AssertionError("engine path must not rescan the prefix")
472
- )
473
- structured_req.reasoner = reasoner
474
-
475
- new_token_ids = [9, 198, 201, 271]
476
- mock_request_with_structured_output.all_token_ids = [1, 2, 3] + new_token_ids
477
-
478
- assert manager_with_reasoner.should_advance(
479
- mock_request_with_structured_output, new_token_ids=new_token_ids
480
- )
481
- assert structured_req.reasoning_ended is True
482
- assert structured_req.reasoning_end_token_index == 5
483
- assert manager_with_reasoner.trim_reasoning_for_advance(
484
- mock_request_with_structured_output, new_token_ids
485
- ) == [271]
486
-
487
- def test_should_advance_engine_adapter_tool_start_ends_reasoning(
488
- self,
489
- manager_with_reasoner,
490
- mock_request_with_structured_output,
491
- ):
492
- structured_req = mock_request_with_structured_output.structured_output_request
493
- structured_req.reasoning_ended = False
494
- structured_req.reasoner = make_qwen3_reasoner()
495
-
496
- new_token_ids = [9, 202, 271]
497
- mock_request_with_structured_output.all_token_ids = [1, 2, 3] + new_token_ids
498
-
499
- assert manager_with_reasoner.should_advance(
500
- mock_request_with_structured_output, new_token_ids=new_token_ids
501
- )
502
- assert structured_req.reasoning_end_token_index == 4
503
-
504
- def test_should_advance_engine_adapter_no_boundary(
505
- self,
506
- manager_with_reasoner,
507
- mock_request_with_structured_output,
508
- ):
509
- structured_req = mock_request_with_structured_output.structured_output_request
510
- structured_req.reasoning_ended = False
511
- structured_req.reasoner = make_qwen3_reasoner()
512
-
513
- new_token_ids = [9, 198, 271]
514
- mock_request_with_structured_output.all_token_ids = [1, 2, 3] + new_token_ids
515
-
516
- assert not manager_with_reasoner.should_advance(
517
- mock_request_with_structured_output, new_token_ids=new_token_ids
518
- )
519
- assert structured_req.reasoning_ended is False
520
-
521
- def test_should_advance_engine_adapter_never_reads_history(
522
- self,
523
- manager_with_reasoner,
524
- mock_request_with_structured_output,
525
- ):
526
- """Per-step cost must not depend on how much has been generated."""
527
- structured_req = mock_request_with_structured_output.structured_output_request
528
- structured_req.reasoning_ended = False
529
- structured_req.reasoner = make_qwen3_reasoner()
530
-
531
- new_token_ids = [9, 201]
532
- total = (1 << 20) + len(new_token_ids)
533
- mock_request_with_structured_output.all_token_ids = UntouchableTokenIds(total)
534
-
535
- assert manager_with_reasoner.should_advance(
536
- mock_request_with_structured_output, new_token_ids=new_token_ids
537
- )
538
- assert structured_req.reasoning_end_token_index == total - 1
539
-
540
- def test_should_advance_engine_adapter_without_new_tokens_persists(
541
- self,
542
- manager_with_reasoner,
543
- mock_request_with_structured_output,
544
- ):
545
- """Draft-validation callers persist the boundary like the legacy path."""
546
- structured_req = mock_request_with_structured_output.structured_output_request
547
- structured_req.reasoning_ended = False
548
- structured_req.reasoner = make_qwen3_reasoner()
549
-
550
- mock_request_with_structured_output.all_token_ids = [1, 2, 3, 4, 5, 201, 7]
551
- # placeholder window: start = 7 - 2 = 5, delta = [201, 7]
552
- mock_request_with_structured_output.num_computed_tokens = 7
553
- mock_request_with_structured_output.num_output_placeholders = 2
554
-
555
- assert manager_with_reasoner.should_advance(mock_request_with_structured_output)
556
- assert structured_req.reasoning_ended is True
557
- assert structured_req.reasoning_end_token_index == 5
tests/v1/structured_output/test_structured_output_manager.py
new
+414
@@ -0,0 +1,414 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import time
5
+from dataclasses import dataclass
6
+
7
+import pytest
8
+from transformers import AutoTokenizer
9
+
10
+from vllm.config import DeviceConfig, StructuredOutputsConfig, VllmConfig
11
+from vllm.config.model import ModelConfig
12
+from vllm.config.speculative import SpeculativeConfig
13
+from vllm.sampling_params import SamplingParams, StructuredOutputsParams
14
+from vllm.v1.request import Request
15
+from vllm.v1.structured_output import StructuredOutputManager
16
+
17
+TOKENIZER = "gpt2"
18
+THINK_END = "\n" # reasoning-end marker (single GPT-2 token)
19
+EOS = "<|eos|>" # resolved to tokenizer.eos_token_id
20
+JSON_SCHEMA = '{"type": "object"}'
21
+BACKENDS = ("xgrammar", "guidance")
22
+MAX_WAIT_SECONDS = 5
23
+NUM_SPEC_TOKENS = 8
24
+
25
+
26
+@pytest.fixture(scope="module")
27
+def tokenizer():
28
+ return AutoTokenizer.from_pretrained(TOKENIZER)
29
+
30
+
31
+@dataclass(frozen=True)
32
+class FlowCase:
33
+ """One validate -> bitmask -> accept -> post-accept bitmask scenario."""
34
+
35
+ raw_drafts: tuple[str, ...]
36
+ expected_row_pattern: str
37
+ expected_reasoning: bool | None
38
+ expect_terminated: bool
39
+ prefix: str = ""
40
+ expected_validated: tuple[str, ...] | None = None
41
+ reasoning_ended: bool | None = False
42
+ xfail_guidance: str | None = None
43
+
44
+
45
+class MockReasoner:
46
+ def __init__(self, tokenizer, marker: int | None = None):
47
+ self.marker = marker
48
+
49
+ def is_reasoning_end(self, input_ids):
50
+ if self.marker is None:
51
+ return True
52
+ return self.marker in list(input_ids)
53
+
54
+ def is_reasoning_end_streaming(self, input_ids, delta_ids):
55
+ return self.is_reasoning_end(delta_ids)
56
+
57
+
58
+def _single_token(tokenizer, text: str) -> int:
59
+ token_ids = tokenizer.encode(text)
60
+ assert len(token_ids) == 1, (text, token_ids)
61
+ return token_ids[0]
62
+
63
+
64
+def _to_token_ids(tokenizer, texts: tuple[str, ...]) -> list[int]:
65
+ """Convert literal text (or EOS sentinel) to token IDs."""
66
+ return [
67
+ tokenizer.eos_token_id if t == EOS else _single_token(tokenizer, t)
68
+ for t in texts
69
+ ]
70
+
71
+
72
+def _wait_for_grammar(request: Request) -> None:
73
+ structured_req = request.structured_output_request
74
+ assert structured_req is not None
75
+
76
+ deadline = time.time() + MAX_WAIT_SECONDS
77
+ while not structured_req._check_grammar_completion():
78
+ if time.time() > deadline:
79
+ pytest.fail("Grammar compilation timed out")
80
+ time.sleep(0.01)
81
+
82
+
83
+def _build_harness(
84
+ tokenizer,
85
+ backend: str,
86
+ prefix: str = "",
87
+ use_reasoner: bool = True,
88
+ reasoning_ended: bool | None = None,
89
+ enable_in_reasoning: bool = False,
90
+ reasoning_parser_kwargs: dict | None = None,
91
+) -> tuple[StructuredOutputManager, Request]:
92
+ vllm_config = VllmConfig(
93
+ model_config=ModelConfig(tokenizer=TOKENIZER),
94
+ device_config=DeviceConfig(device="cpu"),
95
+ structured_outputs_config=StructuredOutputsConfig(
96
+ backend=backend,
97
+ enable_in_reasoning=enable_in_reasoning,
98
+ ),
99
+ speculative_config=SpeculativeConfig(
100
+ model="[ngram]",
101
+ num_speculative_tokens=NUM_SPEC_TOKENS,
102
+ ),
103
+ )
104
+ manager = StructuredOutputManager(vllm_config)
105
+ if use_reasoner:
106
+ manager.reasoner_cls = MockReasoner
107
+
108
+ sampling_params = SamplingParams(
109
+ structured_outputs=StructuredOutputsParams(json=JSON_SCHEMA)
110
+ )
111
+ sampling_params.structured_outputs._backend = backend
112
+ sampling_params.update_from_generation_config({}, tokenizer.eos_token_id)
113
+
114
+ prompt_ids = tokenizer.encode(prefix) if prefix else []
115
+ request = Request(
116
+ request_id=f"{backend}-flow",
117
+ prompt_token_ids=prompt_ids,
118
+ sampling_params=sampling_params,
119
+ pooling_params=None,
120
+ reasoning_ended=reasoning_ended,
121
+ reasoning_parser_kwargs=reasoning_parser_kwargs,
122
+ )
123
+ manager.grammar_init(request)
124
+ _wait_for_grammar(request)
125
+
126
+ structured_req = request.structured_output_request
127
+ assert request.prompt_token_ids is not None
128
+ assert structured_req is not None
129
+ assert structured_req.grammar is not None
130
+ if prompt_ids:
131
+ assert structured_req.grammar.accept_tokens(
132
+ request.request_id,
133
+ prompt_ids,
134
+ )
135
+ return manager, request
136
+
137
+
138
+def _row_pattern(bitmask) -> str:
139
+ return "".join("U" if (row == -1).all() else "C" for row in bitmask)
140
+
141
+
142
+def _run_real_flow(
143
+ manager: StructuredOutputManager,
144
+ request: Request,
145
+ raw_drafts: list[int],
146
+ expected_validated: list[int],
147
+ expected_row_pattern: str,
148
+ expected_reasoning: bool | None,
149
+ expect_terminated: bool,
150
+) -> None:
151
+ assert len(expected_validated) <= len(raw_drafts)
152
+
153
+ validated = manager.validate_tokens(request, list(raw_drafts))
154
+ assert validated == expected_validated
155
+
156
+ padded = validated + [-1] * (len(raw_drafts) - len(validated))
157
+ bitmask = manager.grammar_bitmask(
158
+ requests={request.request_id: request},
159
+ structured_output_request_ids=[request.request_id],
160
+ scheduled_spec_decode_tokens={request.request_id: padded},
161
+ )
162
+ assert bitmask is not None
163
+ assert bitmask.shape[0] == len(raw_drafts) + 1
164
+ assert _row_pattern(bitmask) == expected_row_pattern
165
+
166
+ # grammar_bitmask() must rollback any speculative state it advanced.
167
+ assert manager.validate_tokens(request, list(raw_drafts)) == expected_validated
168
+
169
+ structured_req = request.structured_output_request
170
+ assert structured_req is not None
171
+ grammar = structured_req.grammar
172
+ assert grammar is not None
173
+ assert not grammar.is_terminated()
174
+ # `bitmask[i]` is the grammar state after the first `i` scheduled tokens.
175
+ # These tests commit the validated prefix, so the matching post-accept
176
+ # state is already present in the scheduled bitmask.
177
+ expected_post_accept_row = bitmask[len(expected_validated)].copy()
178
+
179
+ # Mirror the scheduler flow: sampled tokens are appended before
180
+ # StructuredOutputManager.accept_tokens() trims them to the
181
+ # grammar-constrained suffix.
182
+ request.append_output_token_ids(list(expected_validated))
183
+ assert manager.accept_tokens(request, list(expected_validated))
184
+
185
+ post_accept_bitmask = manager.grammar_bitmask(
186
+ requests={request.request_id: request},
187
+ structured_output_request_ids=[request.request_id],
188
+ scheduled_spec_decode_tokens={},
189
+ )
190
+ assert post_accept_bitmask is not None
191
+ assert post_accept_bitmask.shape[0] == 1
192
+ assert (post_accept_bitmask[0] == expected_post_accept_row).all()
193
+ assert structured_req.reasoning_ended == expected_reasoning
194
+ assert grammar.is_terminated() is expect_terminated
195
+
196
+
197
+FLOW_CASES = [
198
+ pytest.param(
199
+ FlowCase(
200
+ raw_drafts=(" ", "z", " "),
201
+ expected_row_pattern="UUUU",
202
+ expected_reasoning=False,
203
+ expect_terminated=False,
204
+ ),
205
+ id="inactive",
206
+ ),
207
+ pytest.param(
208
+ FlowCase(
209
+ prefix='{"a"',
210
+ raw_drafts=(":", ' "', "b", '"}'),
211
+ expected_row_pattern="CCCCC",
212
+ expected_reasoning=True,
213
+ expect_terminated=False,
214
+ reasoning_ended=True,
215
+ ),
216
+ id="active_all_valid",
217
+ ),
218
+ pytest.param(
219
+ FlowCase(
220
+ prefix='{"a"',
221
+ raw_drafts=("z", ":", ' "', "b"),
222
+ expected_validated=(),
223
+ expected_row_pattern="CUUUU",
224
+ expected_reasoning=True,
225
+ expect_terminated=False,
226
+ reasoning_ended=True,
227
+ ),
228
+ id="active_first_token_invalid",
229
+ ),
230
+ pytest.param(
231
+ FlowCase(
232
+ prefix='{"a"',
233
+ raw_drafts=(":", "z", ' "', "b"),
234
+ expected_validated=(":",),
235
+ expected_row_pattern="CCUUU",
236
+ expected_reasoning=True,
237
+ expect_terminated=False,
238
+ reasoning_ended=True,
239
+ ),
240
+ id="active_later_token_invalid",
241
+ ),
242
+ pytest.param(
243
+ FlowCase(
244
+ prefix='{"a": "b"}',
245
+ raw_drafts=(EOS, " ", " "),
246
+ expected_validated=(EOS,),
247
+ expected_row_pattern="CUUU",
248
+ expected_reasoning=True,
249
+ expect_terminated=True,
250
+ reasoning_ended=True,
251
+ xfail_guidance=(
252
+ "guidance validate_tokens rejects EOS on an already-complete "
253
+ "object even though accept_tokens would accept it"
254
+ ),
255
+ ),
256
+ id="active_first_token_terminates",
257
+ ),
258
+ pytest.param(
259
+ FlowCase(
260
+ prefix='{"a"',
261
+ raw_drafts=(":", ' "', "b", '"}', EOS, " "),
262
+ expected_validated=(":", ' "', "b", '"}', EOS),
263
+ expected_row_pattern="CCCCCUU",
264
+ expected_reasoning=True,
265
+ expect_terminated=True,
266
+ reasoning_ended=True,
267
+ ),
268
+ id="active_later_token_terminates",
269
+ ),
270
+ pytest.param(
271
+ FlowCase(
272
+ raw_drafts=(THINK_END, "{", ' "', "b"),
273
+ expected_row_pattern="UCCCC",
274
+ expected_reasoning=True,
275
+ expect_terminated=False,
276
+ ),
277
+ id="becomes_active_on_first_token",
278
+ ),
279
+ pytest.param(
280
+ FlowCase(
281
+ raw_drafts=(" ", THINK_END, "{", ' "'),
282
+ expected_row_pattern="UUCCC",
283
+ expected_reasoning=True,
284
+ expect_terminated=False,
285
+ ),
286
+ id="becomes_active_on_middle_token",
287
+ ),
288
+ pytest.param(
289
+ FlowCase(
290
+ raw_drafts=(" ", " ", THINK_END),
291
+ expected_row_pattern="UUUC",
292
+ expected_reasoning=True,
293
+ expect_terminated=False,
294
+ ),
295
+ id="becomes_active_on_last_token",
296
+ ),
297
+ pytest.param(
298
+ FlowCase(
299
+ raw_drafts=(" ", THINK_END, "z", "{"),
300
+ expected_validated=(" ", THINK_END),
301
+ expected_row_pattern="UUCUU",
302
+ expected_reasoning=True,
303
+ expect_terminated=False,
304
+ ),
305
+ id="becomes_active_first_token_invalid",
306
+ ),
307
+ pytest.param(
308
+ FlowCase(
309
+ raw_drafts=(" ", THINK_END, "{", "z"),
310
+ expected_validated=(" ", THINK_END, "{"),
311
+ expected_row_pattern="UUCCU",
312
+ expected_reasoning=True,
313
+ expect_terminated=False,
314
+ ),
315
+ id="becomes_active_later_token_invalid",
316
+ ),
317
+ pytest.param(
318
+ FlowCase(
319
+ raw_drafts=(" ", THINK_END, "{", "}", EOS, " "),
320
+ expected_validated=(" ", THINK_END, "{", "}", EOS),
321
+ expected_row_pattern="UUCCCUU",
322
+ expected_reasoning=True,
323
+ expect_terminated=True,
324
+ ),
325
+ id="becomes_active_terminates",
326
+ ),
327
+]
328
+
329
+
330
+@pytest.mark.parametrize("backend", BACKENDS)
331
+@pytest.mark.parametrize("case", FLOW_CASES)
332
+def test_real_flow(
333
+ tokenizer,
334
+ backend: str,
335
+ case: FlowCase,
336
+):
337
+ if backend == "guidance" and case.xfail_guidance:
338
+ pytest.xfail(case.xfail_guidance)
339
+
340
+ reasoner_kwargs = {"marker": _single_token(tokenizer, THINK_END)}
341
+ manager, request = _build_harness(
342
+ tokenizer,
343
+ backend,
344
+ prefix=case.prefix,
345
+ reasoning_ended=case.reasoning_ended,
346
+ reasoning_parser_kwargs=reasoner_kwargs,
347
+ )
348
+
349
+ raw_drafts = _to_token_ids(tokenizer, case.raw_drafts)
350
+ expected_texts = (
351
+ case.raw_drafts if case.expected_validated is None else case.expected_validated
352
+ )
353
+ expected_validated = _to_token_ids(tokenizer, expected_texts)
354
+
355
+ _run_real_flow(
356
+ manager,
357
+ request,
358
+ raw_drafts=raw_drafts,
359
+ expected_validated=expected_validated,
360
+ expected_row_pattern=case.expected_row_pattern,
361
+ expected_reasoning=case.expected_reasoning,
362
+ expect_terminated=case.expect_terminated,
363
+ )
364
+
365
+
366
+@pytest.mark.parametrize("backend", BACKENDS)
367
+@pytest.mark.parametrize(
368
+ ("use_reasoner", "reasoning_ended", "enable_in_reasoning"),
369
+ [
370
+ pytest.param(True, False, True, id="enable_in_reasoning"),
371
+ pytest.param(True, True, False, id="reasoning_ended"),
372
+ pytest.param(False, False, False, id="no_reasoner"),
373
+ pytest.param(True, None, False, id="inferred"),
374
+ ],
375
+)
376
+def test_initial_constraint_activation(
377
+ tokenizer,
378
+ backend: str,
379
+ use_reasoner: bool,
380
+ reasoning_ended: bool | None,
381
+ enable_in_reasoning: bool,
382
+):
383
+ manager, request = _build_harness(
384
+ tokenizer,
385
+ backend,
386
+ use_reasoner=use_reasoner,
387
+ reasoning_ended=reasoning_ended,
388
+ enable_in_reasoning=enable_in_reasoning,
389
+ reasoning_parser_kwargs={
390
+ "marker": _single_token(tokenizer, THINK_END)
391
+ if reasoning_ended is not None
392
+ else None
393
+ },
394
+ )
395
+
396
+ # "{" is valid JSON start; "z" is not. Truncation proves grammar is active.
397
+ open_brace = _single_token(tokenizer, "{")
398
+ z = _single_token(tokenizer, "z")
399
+ assert manager.validate_tokens(request, [open_brace, z]) == [open_brace]
400
+
401
+ bitmask = manager.grammar_bitmask(
402
+ requests={request.request_id: request},
403
+ structured_output_request_ids=[request.request_id],
404
+ scheduled_spec_decode_tokens={},
405
+ )
406
+ assert bitmask is not None
407
+ request.append_output_token_ids([open_brace])
408
+ assert manager.accept_tokens(request, [open_brace])
409
+
410
+ # Short-circuit paths must not construct a request-local reasoner (#52573).
411
+ if enable_in_reasoning or reasoning_ended is True:
412
+ structured_req = request.structured_output_request
413
+ assert structured_req is not None
414
+ assert structured_req.reasoner is None
vllm/parser/abstract_parser.py
+6
-2
@@ -197,6 +197,8 @@ class Parser:
197
Used by structured engines like `xgrammar` to check if the
198
reasoning content ends in the model output.
199
200
+ Must be a pure function of the input_ids.
201
+
202
Args:
203
input_ids: The token IDs of the model output.
204
@@ -210,6 +212,8 @@ class Parser:
212
"""
213
Check if the reasoning content ends during a decode step.
214
215
+ Must be a pure function of the input_ids and delta_ids.
216
+
217
Args:
218
input_ids: The entire model output token IDs.
219
delta_ids: The last few computed tokens at the current decode step.
@@ -228,8 +232,8 @@ class Parser:
232
233
Returns:
234
The offset within ``token_ids`` of the last reasoning token, or
231
- ``None`` when reasoning does not end in this window. Parsers that
232
- cannot answer from a window alone always return ``None``; callers
235
+ ``len(token_ids)`` when reasoning does not end in this window. Parsers
236
+ that cannot answer from a window alone always return ``None``; callers
237
fall back to :meth:`is_reasoning_end_streaming`.
238
"""
239
return None
vllm/parser/engine/parser_engine.py
+1
-1
@@ -645,7 +645,7 @@ class ParserEngine(Parser):
645
for offset, token_id in enumerate(token_ids):
646
if token_id in end_ids:
647
return offset
648
- return None
648
+ return len(token_ids)
649
650
def is_reasoning_end(self, input_ids: list[int]) -> bool:
651
config = self.parser_engine_config
vllm/v1/core/sched/scheduler.py
+17
-36
@@ -70,8 +70,7 @@ from vllm.v1.outputs import DraftTokenIds, KVConnectorOutput, ModelRunnerOutput
70
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
71
from vllm.v1.spec_decode.dynamic.utils import build_dynamic_sd_schedule_lookup
72
from vllm.v1.spec_decode.metrics import SpecDecodingStats
73
-from vllm.v1.structured_output import StructuredOutputGrammar, StructuredOutputManager
74
-from vllm.v1.structured_output.utils import strip_speculative_padding
73
+from vllm.v1.structured_output import StructuredOutputManager
74
from vllm.v1.utils import record_function_or_nullcontext
75
76
logger = init_logger(__name__)
@@ -2052,33 +2051,18 @@ class Scheduler(SchedulerInterface):
2051
request.status = RequestStatus.FINISHED_STOPPED
2052
stopped = True
2053
2055
- if new_token_ids and self.structured_output_manager.should_advance(
2056
- request, new_token_ids=new_token_ids
2054
+ if new_token_ids and not self.structured_output_manager.accept_tokens(
2055
+ request, new_token_ids
2056
):
2058
- struct_output_request = request.structured_output_request
2059
- assert struct_output_request is not None
2060
- grammar = struct_output_request.grammar
2061
- assert isinstance(grammar, StructuredOutputGrammar)
2062
- # new_token_ids can be a mixed block of reasoning content, then
2063
- # the reasoning end marker, then the start of the grammar content.
2064
- # Trim the reasoning content so the grammar only sees grammar content.
2065
- advance_token_ids = (
2066
- self.structured_output_manager.trim_reasoning_for_advance(
2067
- request, new_token_ids
2068
- )
2057
+ logger.error(
2058
+ "Unexpected: grammar rejected tokens %s for request %s. "
2059
+ "Terminating request.",
2060
+ new_token_ids,
2061
+ req_id,
2062
)
2070
- if advance_token_ids and not grammar.accept_tokens(
2071
- req_id, advance_token_ids
2072
- ):
2073
- logger.error(
2074
- "Unexpected: grammar rejected tokens %s for request %s. "
2075
- "Terminating request.",
2076
- advance_token_ids,
2077
- req_id,
2078
- )
2079
- request.status = RequestStatus.FINISHED_ERROR
2080
- request.resumable = False
2081
- stopped = True
2063
+ request.status = RequestStatus.FINISHED_ERROR
2064
+ request.resumable = False
2065
+ stopped = True
2066
2067
routed_experts = None
2068
if (
@@ -2434,11 +2418,9 @@ class Scheduler(SchedulerInterface):
2418
continue
2419
2420
# Add newly generated spec token ids to the request.
2437
- if self.structured_output_manager.should_advance(request):
2438
- metadata = request.structured_output_request
2439
- spec_token_ids = strip_speculative_padding(spec_token_ids)
2440
- spec_token_ids = metadata.grammar.validate_tokens(spec_token_ids) # type: ignore[union-attr]
2441
- request.spec_token_ids = spec_token_ids
2421
+ request.spec_token_ids = self.structured_output_manager.validate_tokens(
2422
+ request, spec_token_ids
2423
+ )
2424
2425
def update_draft_token_ids_in_output(
2426
self, draft_token_ids: DraftTokenIds, scheduler_output: SchedulerOutput
@@ -2464,10 +2446,9 @@ class Scheduler(SchedulerInterface):
2446
# (needed for chunked prefill case for example).
2447
del spec_token_ids[orig_num_spec_tokens:]
2448
# Filter out spec tokens which do not adhere to the grammar.
2467
- if self.structured_output_manager.should_advance(request):
2468
- metadata = request.structured_output_request
2469
- spec_token_ids = strip_speculative_padding(spec_token_ids)
2470
- spec_token_ids = metadata.grammar.validate_tokens(spec_token_ids) # type: ignore[union-attr]
2449
+ spec_token_ids = self.structured_output_manager.validate_tokens(
2450
+ request, spec_token_ids
2451
+ )
2452
# Pad to original number of spec tokens.
2453
num_invalid_tokens = orig_num_spec_tokens - len(spec_token_ids)
2454
if num_invalid_tokens:
vllm/v1/structured_output/__init__.py
+140
-207
@@ -1,6 +1,5 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-import itertools
3
import multiprocessing
4
from collections.abc import Iterable, Sequence
5
from concurrent.futures import Future, ThreadPoolExecutor
@@ -18,6 +17,7 @@ from vllm.v1.structured_output.backend_types import (
17
StructuredOutputGrammar,
18
)
19
from vllm.v1.structured_output.backend_xgrammar import XgrammarBackend
20
+from vllm.v1.structured_output.utils import strip_speculative_padding
21
22
if TYPE_CHECKING:
23
import numpy as np
@@ -217,6 +217,100 @@ class StructuredOutputManager:
217
) -> Future:
218
return self.executor_for_fillmask.submit(self._fill_bitmasks, batch)
219
220
+ def _get_constraint_start(
221
+ self,
222
+ request: "Request",
223
+ spec_tokens: Sequence[int],
224
+ spec_tokens_committed: bool = False,
225
+ ) -> int:
226
+ """Return the index into `spec_tokens` where tokens should start being
227
+ constrained by the grammar.
228
+ Assumes `spec_tokens` are stripped of -1 padding tokens.
229
+ Returns `len(spec_tokens) + 1` if no token should be constrained after
230
+ accepting all `spec_tokens`.
231
+
232
+ `spec_tokens_committed` is True if spec_tokens is already in
233
+ `request.all_token_ids`.
234
+ """
235
+ if self.enable_in_reasoning:
236
+ return 0
237
+
238
+ structured_req = request.structured_output_request
239
+ assert structured_req is not None
240
+ if structured_req.reasoning_ended:
241
+ return 0
242
+
243
+ reasoner = self._get_reasoner(request)
244
+ if reasoner is None:
245
+ return 0
246
+
247
+ if structured_req.reasoning_ended is None:
248
+ # This should be removed here, but since `openai_gptoss`
249
+ # is an independent code path, it is kept for now.
250
+ # After unifying the `openai_gptoss` and non-`openai_gptoss` styles,
251
+ # it can be removed.
252
+ if reasoner.is_reasoning_end(request.prompt_token_ids or []):
253
+ return 0
254
+ structured_req.reasoning_ended = False
255
+
256
+ num_spec_tokens = len(spec_tokens)
257
+ if num_spec_tokens <= 0:
258
+ return num_spec_tokens + 1
259
+
260
+ # Use `find_reasoning_end_offset` to find constraint start if supported
261
+ if (
262
+ isinstance(reasoner, ParserEngineReasoningAdapter)
263
+ and reasoner.reasoning_end_token_ids
264
+ ):
265
+ offset = reasoner.find_reasoning_end_offset(spec_tokens)
266
+ if offset is not None:
267
+ return offset + 1
268
+
269
+ # Fallback to `find_reasoning_end_offset`
270
+ # TODO: Build a read-only Sequence view over all_token_ids
271
+ # instead of copying the entire all_token_ids into input_ids.
272
+ if spec_tokens_committed:
273
+ if not spec_tokens or not reasoner.is_reasoning_end_streaming(
274
+ request.all_token_ids, spec_tokens
275
+ ):
276
+ return num_spec_tokens + 1
277
+
278
+ input_ids = request.all_token_ids.copy()
279
+ delta_ids = list(spec_tokens)
280
+ else:
281
+ input_ids = request.all_token_ids.copy()
282
+ input_ids.extend(spec_tokens)
283
+ if not reasoner.is_reasoning_end_streaming(input_ids, spec_tokens):
284
+ return num_spec_tokens + 1
285
+ delta_ids = list(spec_tokens)
286
+
287
+ for i in range(num_spec_tokens - 1, 0, -1):
288
+ input_ids.pop()
289
+ delta_ids.pop()
290
+ if not reasoner.is_reasoning_end_streaming(input_ids, delta_ids):
291
+ return i + 1
292
+ return 1
293
+
294
+ def validate_tokens(self, request: "Request", spec_tokens: list[int]) -> list[int]:
295
+ """Return the longest unconstrained or grammar-valid prefix of `spec_tokens`."""
296
+ if not request.use_structured_output:
297
+ return spec_tokens
298
+
299
+ spec_tokens = strip_speculative_padding(spec_tokens)
300
+ constraint_start = self._get_constraint_start(request, spec_tokens)
301
+ if constraint_start >= len(spec_tokens):
302
+ return spec_tokens
303
+
304
+ structured_req = request.structured_output_request
305
+ if TYPE_CHECKING:
306
+ assert structured_req is not None
307
+ grammar = structured_req.grammar
308
+ if TYPE_CHECKING:
309
+ assert isinstance(grammar, StructuredOutputGrammar)
310
+ prefix = spec_tokens[:constraint_start]
311
+ validated = grammar.validate_tokens(spec_tokens[constraint_start:])
312
+ return prefix + validated
313
+
314
def grammar_bitmask(
315
self,
316
requests: dict[str, "Request"],
@@ -264,7 +358,7 @@ class StructuredOutputManager:
358
if TYPE_CHECKING:
359
assert isinstance(grammar, StructuredOutputGrammar)
360
267
- apply_bitmask = self.should_fill_bitmask(request)
361
+ apply_bitmask = self._get_constraint_start(request, ()) == 0
362
batch.append((grammar, cumulative_index, apply_bitmask))
363
if len(batch) == self.fill_bitmask_parallel_batch_size:
364
promises.append(self._async_submit_fill_bitmask(batch))
@@ -288,76 +382,47 @@ class StructuredOutputManager:
382
grammar = structured_output_request.grammar
383
if TYPE_CHECKING:
384
assert isinstance(grammar, StructuredOutputGrammar)
291
- apply_bitmask = self.should_fill_bitmask(request)
292
-
293
- reasoner = None if apply_bitmask else self._get_reasoner(request)
385
386
+ req_tokens = scheduled_spec_decode_tokens.get(req_id, list())
387
+ constraint_start = self._get_constraint_start(
388
+ request, strip_speculative_padding(req_tokens)
389
+ )
390
state_advancements = 0
296
- post_reasoning_end_in_window = False
297
- req_tokens = scheduled_spec_decode_tokens.get(req_id, ())
298
- # Locate the reasoning-end marker in the draft window once.
299
- # Rejected-draft padding (-1) is trailing, so the window is
300
- # cut there and the reasoner never sees a placeholder.
301
- window_start = len(request.all_token_ids)
302
- reasoning_end_index: int | None = None
303
- if reasoner is not None and req_tokens:
304
- window = req_tokens
305
- if -1 in window:
306
- window = window[: window.index(-1)]
307
- reasoning_end_index = self._find_reasoning_end_index(
308
- reasoner,
309
- request.all_token_ids,
310
- window_start,
311
- window,
312
- delta_appended=False,
313
- )
391
+ seen_padding = False
392
+ failed = False
393
for i, token in enumerate(req_tokens):
394
+ apply_bitmask = (
395
+ not failed and not seen_padding and i >= constraint_start
396
+ )
397
self._fill_bitmasks(((grammar, cumulative_index, apply_bitmask),))
316
- advance_grammar = apply_bitmask
398
if token == -1:
318
- apply_bitmask = False
319
- advance_grammar = False
320
- elif not apply_bitmask and window_start + i == reasoning_end_index:
321
- # Reasoning ended mid-window. Constrain the rest
322
- # of the window via bitmask. Skip grammar advance
323
- # through the marker (it is reasoning content);
324
- # try to advance through subsequent drafts so the
325
- # next bitmask row reflects the post-advance state,
326
- # but tolerate rejection since those drafts predate
327
- # the bitmask and are not guaranteed valid.
328
- apply_bitmask = True
329
- advance_grammar = False
330
- post_reasoning_end_in_window = True
331
- if advance_grammar and not grammar.is_terminated():
332
- if post_reasoning_end_in_window:
333
- accepted = bool(grammar.validate_tokens([token]))
334
- if accepted:
335
- accepted = grammar.accept_tokens(req_id, [token])
336
- else:
337
- accepted = grammar.accept_tokens(req_id, [token])
338
- if accepted:
399
+ seen_padding = True
400
+ elif apply_bitmask:
401
+ if not grammar.is_terminated() and grammar.accept_tokens(
402
+ req_id, [token]
403
+ ):
404
state_advancements += 1
340
- elif not post_reasoning_end_in_window:
341
- raise AssertionError(
342
- (token, req_id, scheduled_spec_decode_tokens)
405
+ else:
406
+ failed = True
407
+ logger.error(
408
+ "Unexpected: grammar terminated or rejected draft "
409
+ "token %s for request %s during bitmask fill.",
410
+ token,
411
+ req_id,
412
)
413
cumulative_index += 1
414
+
415
# Diffusion LLMs don't sample a bonus token after the
416
# scheduled positions, so skip its bitmask in that case.
417
if not (self.vllm_config.model_config.is_diffusion and req_tokens):
348
- # bonus_apply must be True when the bonus-row position
349
- # should be grammar-constrained. Two triggers:
350
- # - should_fill_bitmask(request): reasoning was already
351
- # over at step start (or no reasoner /
352
- # enable_in_reasoning).
353
- # - apply_bitmask: reasoning ended mid-window in this
354
- # call and was flipped True after the marker;
355
- # should_fill_bitmask still returns False here because
356
- # reasoning_ended is only persisted later by
357
- # should_advance.
358
- bonus_apply = self.should_fill_bitmask(request) or apply_bitmask
418
+ bonus_apply = (
419
+ not failed
420
+ and not seen_padding
421
+ and constraint_start <= len(req_tokens)
422
+ )
423
self._fill_bitmasks(((grammar, cumulative_index, bonus_apply),))
424
cumulative_index += 1
425
+
426
if state_advancements > 0:
427
grammar.rollback(state_advancements)
428
@@ -370,161 +435,29 @@ class StructuredOutputManager:
435
# and deserialization when sending this to the GPU workers.
436
return bitmask_tensor.numpy()
437
373
- def should_fill_bitmask(self, request: "Request") -> bool:
374
- # NOTE (Hanchen) if enable_in_reasoning is True, it means that
375
- # the model needs to be constrained in reasoning. So we should always
376
- # enable the bitmask filling.
377
- structured_req = request.structured_output_request
378
- if self.enable_in_reasoning or (
379
- structured_req is not None and structured_req.reasoning_ended is True
380
- ):
438
+ def accept_tokens(self, request: "Request", new_token_ids: list[int]) -> bool:
439
+ """Advance grammar with accepted tokens. Returns False on rejection."""
440
+ if not request.use_structured_output or not new_token_ids:
441
return True
442
383
- reasoner = self._get_reasoner(request)
384
- if reasoner is not None:
443
+ structured_req = request.structured_output_request
444
+ if TYPE_CHECKING:
445
assert structured_req is not None
386
- if structured_req.reasoning_ended is None:
387
- # This should be removed here, but since `openai_gptoss`
388
- # is an independent code path, it is kept for now.
389
- # After unifying the `openai_gptoss` and non-`openai_gptoss` styles,
390
- # it can be removed.
391
- structured_req.reasoning_ended = reasoner.is_reasoning_end(
392
- request.prompt_token_ids or []
393
- )
394
- return structured_req.reasoning_ended
395
- return True
396
-
397
- def should_advance(
398
- self,
399
- request: "Request",
400
- new_token_ids: list[int] | None = None,
401
- ) -> bool:
402
- if not request.use_structured_output:
403
- return False
404
-
405
- # To determine whether we can advance the FSM.
406
- # Supports thinking usage where we skip the reasoning components.
446
+ grammar = structured_req.grammar
447
if TYPE_CHECKING:
408
- assert request.structured_output_request is not None
409
- assert request.structured_output_request.grammar is not None
410
- structured_req = request.structured_output_request
411
- if self.enable_in_reasoning or (
412
- structured_req is not None and structured_req.reasoning_ended is True
413
- ):
414
- return True
415
-
416
- # by default, we should always advance
417
- # for cases that don't use thinking mode.
418
- reasoner = self._get_reasoner(request)
419
- if reasoner is None:
420
- return True
448
+ assert isinstance(grammar, StructuredOutputGrammar)
449
422
- assert structured_req is not None
423
-
424
- # Check if reasoning ends in *this* step.
425
- # When the caller passes new_token_ids (the tokens that were just
426
- # appended this step), use it directly as the delta window. The
427
- # placeholder-derived fallback assumes num_output_placeholders ==
428
- # len(new_token_ids), which breaks under async scheduling + spec
429
- # decode when some drafts are rejected (#43388): the placeholder
430
- # count remains > 0 after the step and the computed delta window
431
- # starts past the reasoning-end marker.
432
- all_token_ids = request.all_token_ids
433
- if new_token_ids:
434
- # The tokens were already appended this step, so the step window
435
- # starts exactly len(new_token_ids) from the end.
436
- start = len(all_token_ids) - len(new_token_ids)
437
- delta_ids: Sequence[int] = new_token_ids
438
- else:
439
- # Draft-validation callers arrive here after update_from_output
440
- # has already persisted any boundary found this step, so this
441
- # window only needs to cover tokens not examined yet.
442
- delta_from = request.num_computed_tokens - request.num_output_placeholders
443
- start = (
444
- delta_from
445
- if delta_from >= 0
446
- else max(len(all_token_ids) + delta_from, 0)
447
- )
448
- delta_ids = all_token_ids[start:]
449
- end_index = self._find_reasoning_end_index(
450
- reasoner, all_token_ids, start, delta_ids, delta_appended=True
450
+ constraint_start = self._get_constraint_start(
451
+ request, new_token_ids, spec_tokens_committed=True
452
)
452
- if end_index is not None:
453
- structured_req.reasoning_ended = True
454
- # Record the boundary so the scheduler can exclude reasoning tokens.
455
- structured_req.reasoning_end_token_index = end_index
453
+ # Early return only when the constraint hasn't started.
454
+ # Otherwise, latch structured_req.reasoning_ended.
455
+ if constraint_start > len(new_token_ids):
456
return True
457
-
458
- return False
459
-
460
- @staticmethod
461
- def _find_reasoning_end_index(
462
- reasoner: "ReasoningParser",
463
- all_token_ids: Sequence[int],
464
- start: int,
465
- delta_ids: Sequence[int],
466
- *,
467
- delta_appended: bool,
468
- ) -> int | None:
469
- """Locates the token that ends reasoning within ``delta_ids``.
470
-
471
- ``delta_ids`` occupy positions ``start..``; ``delta_appended`` says
472
- whether they are already in ``all_token_ids`` (accepted tokens) or
473
- not (speculative drafts).
474
-
475
- Legacy parsers are probed one token at a time. Accepted tokens get a
476
- whole-delta check as well, and a marker seen only that way reports
477
- the final token. Drafts skip it: some predicates are order-sensitive
478
- over a multi-token window and would miss the marker.
479
-
480
- Returns:
481
- Absolute index of the last reasoning token, or ``None``.
482
- """
483
- if (
484
- isinstance(reasoner, ParserEngineReasoningAdapter)
485
- and reasoner.reasoning_end_token_ids
486
- ):
487
- offset = reasoner.find_reasoning_end_offset(delta_ids)
488
- return None if offset is None else start + offset
489
-
490
- if delta_appended and not reasoner.is_reasoning_end_streaming(
491
- all_token_ids, delta_ids
492
- ):
493
- return None
494
- prefix = list(itertools.islice(all_token_ids, start))
495
- for offset, token in enumerate(delta_ids):
496
- prefix.append(token)
497
- if reasoner.is_reasoning_end_streaming(prefix, [token]):
498
- return start + offset
499
- if delta_appended:
500
- return len(all_token_ids) - 1
501
- return None
502
-
503
- def trim_reasoning_for_advance(
504
- self, request: "Request", new_token_ids: list[int]
505
- ) -> list[int]:
506
- """Drops reasoning content from tokens about to advance the grammar.
507
-
508
- When reasoning ends mid-step (see should_advance), the step's output
509
- still contains reasoning tokens up to and including the end marker.
510
- Those are not grammar content: feeding them to accept_tokens makes
511
- the grammar reject the marker and kills the request (#44006).
512
-
513
- Returns:
514
- The suffix of ``new_token_ids`` that follows the reasoning-end
515
- marker. Steps fully after the boundary are returned unchanged.
516
- """
517
- structured_req = request.structured_output_request
518
- if structured_req is None:
519
- return new_token_ids
520
- end_idx = structured_req.reasoning_end_token_index
521
- if end_idx is None:
522
- return new_token_ids
523
- first_idx = len(request.all_token_ids) - len(new_token_ids)
524
- num_reasoning = end_idx + 1 - first_idx
525
- if num_reasoning <= 0:
526
- return new_token_ids
527
- return new_token_ids[num_reasoning:]
457
+ structured_req.reasoning_ended = True
458
+ return grammar.accept_tokens(
459
+ request.request_id, new_token_ids[constraint_start:]
460
+ )
461
462
def clear_backend(self) -> None:
463
if self.backend is not None:
vllm/v1/structured_output/request.py
-6
@@ -25,12 +25,6 @@ class StructuredOutputRequest:
25
Future[StructuredOutputGrammar] | StructuredOutputGrammar | Exception | None
26
) = None
27
reasoning_ended: bool | None = None
28
- # Absolute index into the request's all_token_ids of the last reasoning
29
- # token (the reasoning-end marker). Tokens at or before this index are
30
- # reasoning content and must never be fed to the grammar. Only set when
31
- # reasoning ends in a step whose tokens the scheduler advances immediately
32
- # (structural tags + speculative decoding, see #42452).
33
- reasoning_end_token_index: int | None = None
28
reasoning_parser_kwargs: dict[str, Any] | None = None
29
# Cached per request; do not share reasoning parsers across requests because
30
# their behavior can depend on reasoning_parser_kwargs.