Files
153a17166e [Frontend] Add the Beam reasoning and tool parsers (#62052)
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
Co-authored-by: Bugen Zhao <i@bugenzhao.com>
Co-authored-by: Yoni Gozlan <74535834+yonigozlan@users.noreply.github.com>
Co-authored-by: neilkg <33635204+neilkg@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:08:29 -07:00

263 lines
10 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Parser for Beam checkpoints."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, cast
from openai.types.responses import ToolChoiceFunction
from vllm import envs
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionNamedToolChoiceParam,
ChatCompletionRequest,
)
from vllm.exceptions import VLLMValidationError
from vllm.logger import init_logger
from vllm.parser.abstract_parser import (
_xgrammar_supports,
structured_outputs_to_format,
)
from vllm.parser.chat_parsing import ResponseParser
from vllm.parser.chat_parsing.response_templates import ResponseTemplate
from vllm.parser.response_template import (
CONTENT_FIELD,
THINKING_FIELD,
TOOL_FIELD,
ResponseTemplateParser,
ResponseTemplateReasoningParser,
ResponseTemplateToolParser,
_as_tool_dicts,
_is_named_tool_choice,
_tool_parsing_enabled,
)
from vllm.parser.response_template_grammar import (
_openers,
answer_rest_structural_tag,
message_start_anchors,
tool_call_structural_tag,
)
from vllm.sampling_params import StructuredOutputsParams
from vllm.tool_parsers.structural_tag_registry import resolve_tool_strictness
if TYPE_CHECKING:
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
logger = init_logger(__name__)
def _named_tool(request: ChatCompletionRequest | ResponsesRequest) -> str:
choice = request.tool_choice
if isinstance(choice, ToolChoiceFunction):
return choice.name
return cast(ChatCompletionNamedToolChoiceParam, choice).function.name
def _starts_message(template: ResponseTemplate, prefix: str) -> bool:
"""Whether output after `prefix` starts the assistant message."""
ends = [match.end() for match in template.start_anchor_re.finditer(prefix)]
return bool(ends) and ends[-1] == len(prefix)
def _open_field(template: ResponseTemplate, prefix: str) -> str | None:
"""The field that `prefix` leaves open with its explicit opener, if any."""
field = None
for event in ResponseParser(template, prefix=prefix).initial_events:
if event["type"] == "region_open" and event["end"] > event["start"]:
field = event["field"]
elif event["type"] in {"region_close", "region_malformed"}:
field = None
return field
class BeamReasoningParser(ResponseTemplateReasoningParser):
"""Selects `BeamParser` as the reasoning parser."""
@classmethod
def unified_parser_cls(cls) -> type[ResponseTemplateParser]:
return BeamParser
@property
def reasoning_start_str(self) -> str | None:
if literal := super().reasoning_start_str:
return literal
thinking = self.response_template.fields.get(THINKING_FIELD)
if thinking is None:
return None
openers = _openers(thinking, message_start_anchors(self.response_template))
if not openers:
return None
# Budget tracking needs one marker for both a prompt continuation
# and a complete assistant message in the checkpoint's pattern.
marker = min(openers, key=len)
return marker if all(opener.endswith(marker) for opener in openers) else None
class BeamToolParser(ResponseTemplateToolParser):
"""Selects `BeamParser` as the tool parser."""
@classmethod
def unified_parser_cls(cls) -> type[ResponseTemplateParser]:
return BeamParser
class BeamParser(ResponseTemplateParser):
"""Parse Beam output with the checkpoint's `response_template` metadata,
and constrain it with a grammar derived from that template."""
reasoning_parser_cls = BeamReasoningParser
tool_parser_cls = BeamToolParser
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
chat_template_kwargs = kwargs.get("chat_template_kwargs") or {}
# From the message start, the grammar opens the message with non-empty
# reasoning, unless the request turns thinking off.
self._requires_reasoning = bool(
chat_template_kwargs.get("enable_thinking", True)
)
def _constrain_request(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> ChatCompletionRequest | ResponsesRequest:
message_start = _starts_message(self.response_template, self._prefix)
tag = self._template_tag(request, message_start=message_start)
if tag is not None:
grammar = StructuredOutputsParams(structural_tag=json.dumps(tag))
if isinstance(request, ChatCompletionRequest):
request.structured_outputs = grammar
request.response_format = None
else:
request._parser_structured_outputs = grammar
# From the message start, the tag also covers the reasoning.
request._grammar_from_parser = message_start
return request
def _template_tag(
self,
request: ChatCompletionRequest | ResponsesRequest,
*,
message_start: bool,
) -> dict[str, Any] | None:
"""The template grammar for `request`, or `None` to leave its output
to the request's own constraint, if any."""
fields = self.response_template.fields
named = _is_named_tool_choice(request)
tools_enabled = (
self._tool_parser is not None
and bool(request.tools)
and _tool_parsing_enabled(
request, enable_auto_tools=self._enable_auto_tools
)
)
forced = tools_enabled and (request.tool_choice == "required" or named)
# The prompt may still list the tools, so only the grammar keeps the
# model from calling them.
tools_forbidden = (
bool(request.tools)
and request.tool_choice == "none"
and TOOL_FIELD in fields
)
constrained_tools = None
if tools_enabled and (forced or envs.VLLM_ENFORCE_STRICT_TOOL_CALLING):
assert self._tool_parser is not None
strict_level = self.tool_strict_level
if strict_level is None:
strict_level = self._tool_parser.default_tool_strict_level
constrained_tools = resolve_tool_strictness(
request.tools or [], request.tool_choice, strict_level
)
output = request.extract_structured_outputs()
if forced and output is not None:
logger.warning_once(
"structured outputs are ignored because tool_choice forces tool call."
)
output = None
answer = fields.get(CONTENT_FIELD)
if output is not None and (
output.structural_tag is not None
or answer is None
or answer.open_re is None
or not _xgrammar_supports(output)
):
if constrained_tools is not None:
logger.warning_once(
"response_template parsing cannot enforce strict tools "
"alongside this structured output."
)
return None
# Without a tool or structured output constraint, the output still
# follows the template.
always_constrain = envs.VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT and not forced
if (
constrained_tools is None
and output is None
and not tools_forbidden
and not always_constrain
):
return None
# Inside an open answer, only the always-on grammar applies, finishing a
# free answer. Inside open reasoning, the grammar needs the reasoning
# gate to defer it to the reasoning's end.
open_field = _open_field(self.response_template, self._prefix)
reasoning_gated = (
self._reasoning_parser is not None and request.include_reasoning
)
tag = None
if open_field == CONTENT_FIELD:
if always_constrain and output is None:
tag = answer_rest_structural_tag(fields[CONTENT_FIELD])
elif open_field != THINKING_FIELD or reasoning_gated:
functions = []
if tools_enabled:
tools = _as_tool_dicts(constrained_tools or request.tools)
functions = [tool.get("function", tool) for tool in tools]
if named:
name = _named_tool(request)
functions = [f for f in functions if f.get("name") == name]
answer_format = (
None if output is None else structured_outputs_to_format(output)
)
try:
tag = tool_call_structural_tag(
fields.get(TOOL_FIELD),
functions,
parallel_tool_calls=(
not named and request.parallel_tool_calls is not False
),
thinking=fields.get(THINKING_FIELD),
answer=None if forced else answer,
answer_format=(
None if answer_format is None else answer_format.model_dump()
),
message_start=(
message_start_anchors(self.response_template)
if message_start
else None
),
requires_reasoning=self._requires_reasoning,
# Beam drifts from schemas the grammar does not enforce,
# and also writes arguments sorted by name.
sorted_arguments=True,
strict_arguments=True,
)
except ValueError as exc:
raise VLLMValidationError(str(exc), parameter="tools") from exc
if tag is None and forced:
kind = "named" if named else "required"
raise VLLMValidationError(
f"response_template parsing cannot guarantee {kind} tool choice "
"for this request",
parameter="tool_choice",
)
if tag is None and constrained_tools is not None:
logger.warning_once(
"response_template parsing cannot enforce strict tools for this "
"request."
)
return tag