| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- #!/usr/bin/env python3
- """Patch vLLM structured-output reasoning-boundary bugs.
- Ports upstream vLLM PRs #44297 and #44993 to the b12x fork's vLLM source.
- Background
- ----------
- When a reasoning parser (e.g. glm45 -> Glm47MoeParser) emits the reasoning-end
- marker (</think>) under speculative decoding (MTP, num_speculative_tokens=4),
- the grammar FSM (used by response_format / guided decoding) fails to advance
- across the reasoning->content boundary. This is vLLM bug #34650, fixed
- upstream by:
- #44297 - Constrain bitmask and trim grammar advance at the reasoning boundary
- #44993 - Advance grammar across reasoning boundary (supersedes #44993's
- earlier conservative version; final merged form)
- Without this patch, models emit prose or markdown-fence prefixes before JSON
- in the content channel (e.g. ```{..." or "Here{..."), causing structured
- output validation failures. The tool-eval-bench Structured Output category
- scores 50% (6/12) instead of 100%.
- The fix
- -------
- 1. should_advance() accepts an optional new_token_ids argument and uses it as
- the authoritative step delta (instead of reconstructing the window from
- num_computed_tokens - num_output_placeholders, which skips the marker
- under async scheduling + spec-decode draft rejection).
- 2. At the boundary, should_advance() records reasoning_end_token_index and
- returns True for ALL constraint types (not just STRUCTURAL_TAG under spec
- decode). The scheduler then trims reasoning content before accept_tokens.
- 3. grammar_bitmask() detects a mid-window reasoning-end marker and constrains
- subsequent draft positions via the bitmask, skipping grammar advance
- through the marker itself.
- 4. The scheduler's update_from_output() passes new_token_ids to should_advance
- and calls trim_reasoning_for_advance() before accept_tokens.
- Fixes: #34650, #43388, #48228.
- """
- import sys
- from pathlib import Path
- VLLM_DIR = Path("/opt/venv/lib/python3.12/site-packages/vllm")
- def patch_request():
- """Add reasoning_end_token_index field to StructuredOutputRequest."""
- path = VLLM_DIR / "v1" / "structured_output" / "request.py"
- content = path.read_text()
- if "reasoning_end_token_index" in content:
- print(f" {path.name}: already patched, skipping")
- return
- old = " reasoning_ended: bool | None = None\n"
- new = (
- " reasoning_ended: bool | None = None\n"
- " # Absolute index into the request's all_token_ids of the last\n"
- " # reasoning token (the reasoning-end marker). Tokens at or before\n"
- " # this index are reasoning content and must never be fed to the\n"
- " # grammar. Only set when reasoning ends in a step whose tokens\n"
- " # the scheduler advances immediately (structural tags +\n"
- " # speculative decoding, see #42452).\n"
- " reasoning_end_token_index: int | None = None\n"
- )
- if old not in content:
- print(f" {path.name}: ERROR - could not find reasoning_ended field",
- file=sys.stderr)
- sys.exit(1)
- content = content.replace(old, new, 1)
- path.write_text(content)
- print(f" {path.name}: patched")
- def patch_init():
- """Patch __init__.py: imports, grammar_bitmask, should_advance, helpers."""
- path = VLLM_DIR / "v1" / "structured_output" / "__init__.py"
- content = path.read_text()
- if "trim_reasoning_for_advance" in content:
- print(f" {path.name}: already patched, skipping")
- return
- # 1. Add Sequence to collections.abc import
- old_import = "from collections.abc import Iterable\n"
- new_import = "from collections.abc import Iterable, Sequence\n"
- if old_import not in content:
- print(f" {path.name}: ERROR - could not find Iterable import",
- file=sys.stderr)
- sys.exit(1)
- content = content.replace(old_import, new_import, 1)
- # 2. Remove StructuredOutputOptions from backend_types import
- old_bo = (
- "from vllm.v1.structured_output.backend_types import (\n"
- " StructuredOutputBackend,\n"
- " StructuredOutputGrammar,\n"
- " StructuredOutputOptions,\n"
- ")"
- )
- new_bo = (
- "from vllm.v1.structured_output.backend_types import (\n"
- " StructuredOutputBackend,\n"
- " StructuredOutputGrammar,\n"
- ")"
- )
- if old_bo not in content:
- print(f" {path.name}: ERROR - could not find backend_types import",
- file=sys.stderr)
- sys.exit(1)
- content = content.replace(old_bo, new_bo, 1)
- # 3. Remove logger import and initialization (unused after should_advance
- # no longer references StructuredOutputOptions)
- old_logger_import = "from vllm.logger import init_logger\n"
- if old_logger_import in content:
- content = content.replace(old_logger_import, "", 1)
- old_logger_init = "logger = init_logger(__name__)\n\n\n"
- if old_logger_init in content:
- content = content.replace(old_logger_init, "", 1)
- # 4. Replace grammar_bitmask serial-filling loop
- old_bitmask = ''' state_advancements = 0
- req_tokens = scheduled_spec_decode_tokens.get(req_id, ())
- if self.vllm_config.model_config.is_diffusion and req_tokens:
- # Diffusion LLMs don't sample a bonus token after the
- # scheduled positions, so don't append the -1 placeholder.
- token_iter: Iterable[int] = req_tokens
- else:
- token_iter = itertools.chain(req_tokens, (-1,))
- for token in token_iter:
- self._fill_bitmasks(((grammar, cumulative_index, apply_bitmask),))
- if token == -1:
- # Stop advancing the grammar once we hit a padding token.
- apply_bitmask = False
- if apply_bitmask and not grammar.is_terminated():
- accepted = grammar.accept_tokens(req_id, [token])
- assert accepted, (token, req_id, scheduled_spec_decode_tokens)
- state_advancements += 1
- cumulative_index += 1
- if state_advancements > 0:
- grammar.rollback(state_advancements)'''
- new_bitmask = ''' reasoner = self._get_reasoner(request)
- detect_reasoning_end = (
- not apply_bitmask
- and reasoner is not None
- and not self.enable_in_reasoning
- )
- simulated_buf: list[int] | None = None
- history_len = 0
- state_advancements = 0
- post_reasoning_end_in_window = False
- req_tokens = scheduled_spec_decode_tokens.get(req_id, ())
- for i, token in enumerate(req_tokens):
- self._fill_bitmasks(((grammar, cumulative_index, apply_bitmask),))
- advance_grammar = apply_bitmask
- if token == -1:
- apply_bitmask = False
- advance_grammar = False
- elif (
- detect_reasoning_end
- and reasoner is not None
- and not apply_bitmask
- ):
- if simulated_buf is None:
- history = list(request.all_token_ids)
- history_len = len(history)
- simulated_buf = history + list(req_tokens)
- simulated = simulated_buf[: history_len + i + 1]
- if reasoner.is_reasoning_end_streaming(simulated, [token]):
- # Reasoning ended mid-window. Constrain the rest
- # of the window via bitmask. Skip grammar advance
- # through the marker (it is reasoning content);
- # try to advance through subsequent drafts so the
- # next bitmask row reflects the post-advance state,
- # but tolerate rejection since those drafts predate
- # the bitmask and are not guaranteed valid.
- apply_bitmask = True
- advance_grammar = False
- post_reasoning_end_in_window = True
- if advance_grammar and not grammar.is_terminated():
- accepted = grammar.accept_tokens(req_id, [token])
- if accepted:
- state_advancements += 1
- elif not post_reasoning_end_in_window:
- raise AssertionError(
- (token, req_id, scheduled_spec_decode_tokens)
- )
- cumulative_index += 1
- # Diffusion LLMs don't sample a bonus token after the
- # scheduled positions, so skip its bitmask in that case.
- if not (self.vllm_config.model_config.is_diffusion and req_tokens):
- # bonus_apply must be True when the bonus-row position
- # should be grammar-constrained. Two triggers:
- # - should_fill_bitmask(request): reasoning was already
- # over at step start (or no reasoner /
- # enable_in_reasoning).
- # - apply_bitmask: reasoning ended mid-window in this
- # call and was flipped True after the marker;
- # should_fill_bitmask still returns False here because
- # reasoning_ended is only persisted later by
- # should_advance.
- bonus_apply = self.should_fill_bitmask(request) or apply_bitmask
- self._fill_bitmasks(((grammar, cumulative_index, bonus_apply),))
- cumulative_index += 1
- if state_advancements > 0:
- grammar.rollback(state_advancements)'''
- if old_bitmask not in content:
- print(f" {path.name}: ERROR - could not find grammar_bitmask serial loop",
- file=sys.stderr)
- sys.exit(1)
- content = content.replace(old_bitmask, new_bitmask, 1)
- # 5. Replace should_advance and add helper methods
- old_should_advance = ''' def should_advance(self, request: "Request") -> bool:
- if not request.use_structured_output:
- return False
- # To determine whether we can advance the FSM.
- # Supports thinking usage where we skip the reasoning components.
- if TYPE_CHECKING:
- assert request.structured_output_request is not None
- assert request.structured_output_request.grammar is not None
- # by default, we should always advance
- # for cases that don't use thinking mode.
- reasoner = self._get_reasoner(request)
- if reasoner is None:
- return True
- # if the model needs structured in reasoning, we should advance
- if self.enable_in_reasoning:
- return True
- structured_req = request.structured_output_request
- if structured_req.reasoning_ended:
- return True
- # Check if reasoning ends in *this* step
- delta_from = request.num_computed_tokens - request.num_output_placeholders
- all_token_ids = request.all_token_ids
- start = (
- delta_from if delta_from >= 0 else max(len(all_token_ids) + delta_from, 0)
- )
- if reasoner.is_reasoning_end_streaming(
- all_token_ids, itertools.islice(all_token_ids, start, None)
- ):
- structured_req.reasoning_ended = True
- # Reasoning just ended this step. Defer FSM advance until the next
- # pass (see reasoning_ended check above) for JSON/regex/choice/grammar:
- # advancing on the closing boundary token can accept tokens that still
- # belong to the reasoning stream. Structural tags are the only safe
- # same-step exception: they model phased output (e.g. thinking tag ->
- # answer tag), and speculative decoding must run grammar.validate_tokens
- # on draft tokens produced immediately after that transition.
- if (
- self.vllm_config.speculative_config is not None
- and structured_req.structured_output_key[0]
- == StructuredOutputOptions.STRUCTURAL_TAG
- ):
- return True
- return False'''
- new_should_advance = ''' def should_advance(
- self,
- request: "Request",
- new_token_ids: list[int] | None = None,
- ) -> bool:
- if not request.use_structured_output:
- return False
- # To determine whether we can advance the FSM.
- # Supports thinking usage where we skip the reasoning components.
- if TYPE_CHECKING:
- assert request.structured_output_request is not None
- assert request.structured_output_request.grammar is not None
- # by default, we should always advance
- # for cases that don't use thinking mode.
- reasoner = self._get_reasoner(request)
- if reasoner is None:
- return True
- # if the model needs structured in reasoning, we should advance
- if self.enable_in_reasoning:
- return True
- structured_req = request.structured_output_request
- if structured_req.reasoning_ended:
- return True
- # Check if reasoning ends in *this* step.
- # When the caller passes new_token_ids (the tokens that were just
- # appended this step), use it directly as the delta window. The
- # placeholder-derived fallback assumes num_output_placeholders ==
- # len(new_token_ids), which breaks under async scheduling + spec
- # decode when some drafts are rejected (#43388): the placeholder
- # count remains > 0 after the step and the computed delta window
- # starts past the reasoning-end marker.
- all_token_ids = request.all_token_ids
- if new_token_ids:
- # The tokens were already appended this step, so the step window
- # starts exactly len(new_token_ids) from the end.
- start = len(all_token_ids) - len(new_token_ids)
- delta_ids: Iterable[int] = new_token_ids
- else:
- delta_from = request.num_computed_tokens - request.num_output_placeholders
- start = (
- delta_from
- if delta_from >= 0
- else max(len(all_token_ids) + delta_from, 0)
- )
- delta_ids = itertools.islice(all_token_ids, start, None)
- if reasoner.is_reasoning_end_streaming(all_token_ids, delta_ids):
- structured_req.reasoning_ended = True
- # Record the boundary so the scheduler can exclude reasoning tokens.
- end_index = self._find_reasoning_end_index(reasoner, all_token_ids, start)
- structured_req.reasoning_end_token_index = end_index
- return True
- return False
- @staticmethod
- def _find_reasoning_end_index(
- reasoner: "ReasoningParser", all_token_ids: Sequence[int], start: int
- ) -> int:
- """Locates the last reasoning token within all_token_ids[start:].
- Returns the absolute index of the token at which
- is_reasoning_end_streaming first fires. Falls back to the final
- index when no single token triggers the detection (e.g. a
- multi-token marker only recognized on the full delta), which
- conservatively treats the whole step as reasoning content.
- """
- prefix = list(itertools.islice(all_token_ids, start))
- for idx in range(start, len(all_token_ids)):
- token = all_token_ids[idx]
- prefix.append(token)
- if reasoner.is_reasoning_end_streaming(prefix, [token]):
- return idx
- return len(all_token_ids) - 1
- def trim_reasoning_for_advance(
- self, request: "Request", new_token_ids: list[int]
- ) -> list[int]:
- """Drops reasoning content from tokens about to advance the grammar.
- When reasoning ends mid-step (see should_advance), the step's output
- still contains reasoning tokens up to and including the end marker.
- Those are not grammar content: feeding them to accept_tokens makes
- the grammar reject the marker and kills the request (#44006).
- Returns the suffix of new_token_ids that follows the reasoning-end
- marker. Steps fully after the boundary are returned unchanged.
- """
- structured_req = request.structured_output_request
- if structured_req is None:
- return new_token_ids
- end_idx = structured_req.reasoning_end_token_index
- if end_idx is None:
- return new_token_ids
- first_idx = len(request.all_token_ids) - len(new_token_ids)
- num_reasoning = end_idx + 1 - first_idx
- if num_reasoning <= 0:
- return new_token_ids
- return new_token_ids[num_reasoning:]'''
- if old_should_advance not in content:
- print(f" {path.name}: ERROR - could not find should_advance method",
- file=sys.stderr)
- sys.exit(1)
- content = content.replace(old_should_advance, new_should_advance, 1)
- path.write_text(content)
- print(f" {path.name}: patched")
- def patch_scheduler():
- """Patch update_from_output to pass new_token_ids and trim before accept."""
- path = VLLM_DIR / "v1" / "core" / "sched" / "scheduler.py"
- content = path.read_text()
- if "trim_reasoning_for_advance" in content:
- print(f" {path.name}: already patched, skipping")
- return
- old = ''' if new_token_ids and self.structured_output_manager.should_advance(request):
- struct_output_request = request.structured_output_request
- assert struct_output_request is not None
- assert struct_output_request.grammar is not None
- if not struct_output_request.grammar.accept_tokens( # type: ignore[union-attr]
- req_id, new_token_ids
- ):
- logger.error(
- "Unexpected: grammar rejected tokens %s for request %s. "
- "Terminating request.",
- new_token_ids,
- req_id,
- )
- request.status = RequestStatus.FINISHED_ERROR
- request.resumable = False
- stopped = True'''
- new = ''' if new_token_ids and self.structured_output_manager.should_advance(
- request, new_token_ids=new_token_ids
- ):
- struct_output_request = request.structured_output_request
- assert struct_output_request is not None
- grammar = struct_output_request.grammar
- assert grammar is not None
- # new_token_ids can be a mixed block of reasoning content, then
- # the reasoning end marker, then the start of the grammar content.
- # Trim the reasoning content so the grammar only sees grammar content.
- advance_token_ids = (
- self.structured_output_manager.trim_reasoning_for_advance(
- request, new_token_ids
- )
- )
- if advance_token_ids and not grammar.accept_tokens(
- req_id, advance_token_ids
- ):
- logger.error(
- "Unexpected: grammar rejected tokens %s for request %s. "
- "Terminating request.",
- advance_token_ids,
- req_id,
- )
- request.status = RequestStatus.FINISHED_ERROR
- request.resumable = False
- stopped = True'''
- if old not in content:
- print(f" {path.name}: ERROR - could not find should_advance block",
- file=sys.stderr)
- sys.exit(1)
- content = content.replace(old, new, 1)
- path.write_text(content)
- print(f" {path.name}: patched")
- if __name__ == "__main__":
- print("Applying structured output reasoning-boundary patch...")
- print(f" vLLM dir: {VLLM_DIR}")
- patch_request()
- patch_init()
- patch_scheduler()
- print("Done.")
|