| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Backend and engine-reasoner checks that are not manager-flow tests.""" |
| 4 | |
| 5 | from typing import Literal |
| 6 | |
| 7 | import pytest |
| 8 | from transformers import AutoTokenizer |
| 9 | |
| 10 | from vllm.config import StructuredOutputsConfig, VllmConfig |
| 11 | from vllm.config.model import ModelConfig |
| 12 | from vllm.config.speculative import SpeculativeConfig |
| 13 | from vllm.parser.engine.adapters import ParserEngineReasoningAdapter |
| 14 | from vllm.sampling_params import SamplingParams, StructuredOutputsParams |
| 15 | from vllm.v1.request import Request |
| 16 | from vllm.v1.structured_output import StructuredOutputManager |
| 17 | |
| 18 | TOKENIZER = "gpt2" |
| 19 | NUM_SPEC_TOKENS = 4 |
| 20 | |
| 21 | |
| 22 | def _make_manager_and_request( |
| 23 | backend: Literal["xgrammar", "guidance"], prompt_str: str = '{"a": "b"}' |
| 24 | ): |
| 25 | tokenizer = AutoTokenizer.from_pretrained(TOKENIZER) |
| 26 | prompt = tokenizer.encode(prompt_str) |
| 27 | |
| 28 | vllm_config = VllmConfig( |
| 29 | model_config=ModelConfig(tokenizer=TOKENIZER), |
| 30 | structured_outputs_config=StructuredOutputsConfig(backend=backend), |
| 31 | speculative_config=SpeculativeConfig( |
| 32 | model="[ngram]", num_speculative_tokens=NUM_SPEC_TOKENS |
| 33 | ), |
| 34 | ) |
| 35 | manager = StructuredOutputManager(vllm_config) |
| 36 | |
| 37 | sampling_params = SamplingParams( |
| 38 | structured_outputs=StructuredOutputsParams(json='{"type": "object"}'), |
| 39 | ) |
| 40 | assert sampling_params.structured_outputs is not None |
| 41 | sampling_params.structured_outputs._backend = backend |
| 42 | sampling_params.update_from_generation_config({}, tokenizer.eos_token_id) |
| 43 | |
| 44 | request = Request( |
| 45 | "mtp_req", |
| 46 | prompt_token_ids=prompt, |
| 47 | sampling_params=sampling_params, |
| 48 | pooling_params=None, |
| 49 | ) |
| 50 | manager.grammar_init(request) |
| 51 | assert request.structured_output_request is not None |
| 52 | while not request.structured_output_request._check_grammar_completion(): |
| 53 | continue |
| 54 | |
| 55 | return tokenizer, manager, request, prompt |
| 56 | |
| 57 | |
| 58 | def test_xgrammar_accept_tokens_stops_at_termination(capfd): |
| 59 | """Tokens after a terminating EOS do not reach the matcher.""" |
| 60 | tokenizer, _, request, prompt = _make_manager_and_request("xgrammar") |
| 61 | grammar = request.structured_output_request.grammar |
| 62 | |
| 63 | assert grammar.accept_tokens(request.request_id, prompt) |
| 64 | |
| 65 | eos = tokenizer.eos_token_id |
| 66 | trailing = tokenizer.encode("\n")[0] |
| 67 | processed_before = grammar.num_processed_tokens |
| 68 | |
| 69 | assert grammar.accept_tokens(request.request_id, [eos, trailing]) |
| 70 | assert grammar.is_terminated() |
| 71 | assert grammar.num_processed_tokens == processed_before + 1 |
| 72 | assert "trying to accept new token" not in capfd.readouterr().err |
| 73 | |
| 74 | processed_after_eos = grammar.num_processed_tokens |
| 75 | assert grammar.accept_tokens(request.request_id, [trailing]) |
| 76 | assert grammar.num_processed_tokens == processed_after_eos |
| 77 | assert "trying to accept new token" not in capfd.readouterr().err |
| 78 | |
| 79 | grammar.reset() |
| 80 | assert not grammar.is_terminated() |
| 81 | assert grammar.num_processed_tokens == 0 |
| 82 | |
| 83 | |
| 84 | def test_xgrammar_validate_tokens_stops_at_termination(capfd): |
| 85 | """Validation rolls back after reaching a terminating EOS.""" |
| 86 | tokenizer, _, request, prompt = _make_manager_and_request("xgrammar") |
| 87 | grammar = request.structured_output_request.grammar |
| 88 | |
| 89 | assert grammar.accept_tokens(request.request_id, prompt) |
| 90 | |
| 91 | eos = tokenizer.eos_token_id |
| 92 | trailing = tokenizer.encode("\n")[0] |
| 93 | |
| 94 | assert grammar.validate_tokens([eos, trailing]) == [eos] |
| 95 | assert "trying to accept new token" not in capfd.readouterr().err |
| 96 | assert not grammar.matcher.is_terminated() |
| 97 | |
| 98 | assert grammar.accept_tokens(request.request_id, [eos]) |
| 99 | assert grammar.is_terminated() |
| 100 | |
| 101 | assert grammar.validate_tokens([trailing]) == [] |
| 102 | assert "trying to accept new token" not in capfd.readouterr().err |
| 103 | |
| 104 | |
| 105 | class _EngineReasonerStub(ParserEngineReasoningAdapter): |
| 106 | """Adapter-typed reasoner with a fixed end-token set and no real engine.""" |
| 107 | |
| 108 | def __init__(self, end_token_ids): |
| 109 | self._end_token_ids = frozenset(end_token_ids) |
| 110 | self.windows: list[list[int]] = [] |
| 111 | |
| 112 | @property |
| 113 | def reasoning_end_token_ids(self): |
| 114 | return self._end_token_ids |
| 115 | |
| 116 | def find_reasoning_end_offset(self, token_ids): |
| 117 | self.windows.append(list(token_ids)) |
| 118 | for offset, token in enumerate(token_ids): |
| 119 | if token in self._end_token_ids: |
| 120 | return offset |
| 121 | return len(token_ids) |
| 122 | |
| 123 | def is_reasoning_end(self, input_ids): |
| 124 | return any(token in self._end_token_ids for token in input_ids) |
| 125 | |
| 126 | def is_reasoning_end_streaming(self, input_ids, delta_ids): |
| 127 | raise AssertionError("engine path must not rescan draft prefixes") |
| 128 | |
| 129 | |
| 130 | @pytest.mark.parametrize("backend", ["xgrammar", "guidance"]) |
| 131 | def test_bitmask_engine_reasoner_ends_midwindow_with_padding(backend): |
| 132 | """Engine reasoners see the draft window once, without -1 padding.""" |
| 133 | tokenizer, manager, request, prompt = _make_manager_and_request(backend) |
| 134 | grammar = request.structured_output_request.grammar |
| 135 | |
| 136 | assert grammar.accept_tokens(request.request_id, prompt) |
| 137 | |
| 138 | marker = tokenizer.encode("\n")[0] |
| 139 | reasoner = _EngineReasonerStub({marker}) |
| 140 | manager.reasoner_cls = _EngineReasonerStub |
| 141 | request.structured_output_request.reasoner = reasoner |
| 142 | request.structured_output_request.reasoning_ended = False |
| 143 | |
| 144 | pre = tokenizer.encode(" ")[0] |
| 145 | post = tokenizer.encode(",")[0] |
| 146 | drafts = [pre, marker, post, -1] |
| 147 | |
| 148 | bitmask = manager.grammar_bitmask( |
| 149 | requests={request.request_id: request}, |
| 150 | structured_output_request_ids=[request.request_id], |
| 151 | scheduled_spec_decode_tokens={request.request_id: drafts}, |
| 152 | ) |
| 153 | |
| 154 | assert bitmask is not None |
| 155 | assert bitmask.shape[0] == len(drafts) + 1 |
| 156 | assert (bitmask[0] == -1).all() |
| 157 | assert (bitmask[1] == -1).all() |
| 158 | assert not (bitmask[2] == -1).all() |
| 159 | assert reasoner.windows == [[pre, marker, post]] |
| 160 | assert not grammar.is_terminated() |