Skip to content
117 changes: 108 additions & 9 deletions components/src/dynamo/frontend/prepost.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Protocol, cast
from typing import Any, Protocol, TypeGuard, cast

from vllm.entrypoints.chat_utils import make_tool_call_id
from vllm.entrypoints.openai.chat_completion.protocol import (
Expand Down Expand Up @@ -414,16 +414,40 @@ def _validate_chat_completion_request(
return validated_request


def _reasoning_parser_enabled(
reasoning_parser_class: type[ReasoningParser] | None,
chat_template_kwargs: dict[str, Any],
) -> TypeGuard[type[ReasoningParser]]:
"""Whether a reasoning parser runs for this request.

Single-sourced because three call sites must agree: _prepare_request (which
lets the parser adjust the sampling request), preprocess_chat_request (which
must know whether that parser could have rewritten the request's guidance),
and StreamingPostProcessor (which builds the parser that consumes the result).
If they drift, the sampling params get adjusted for a parser that then does not
exist and the model's control markers reach the client as visible text.

See https://github.com/ai-dynamo/dynamo/issues/8636 for the enable_thinking
half: when the chat template runs with enable_thinking=False the reasoning
tags live in the prompt and the generated output carries none.
"""
return reasoning_parser_class is not None and (
chat_template_kwargs.get("enable_thinking") is not False
)


def _prepare_request(
request: dict[str, Any] | ChatCompletionRequest,
*,
tokenizer: TokenizerLike,
tool_parser_class: type[ToolParser] | None,
reasoning_parser_class: type[ReasoningParser] | None = None,
exclude_tools_when_tool_choice_none: bool = True,
enable_auto_tool_choice: bool = False,
default_chat_template_kwargs: dict[str, Any] | None = None,
default_thinking_mode: str | None = None,
validated_request: ChatCompletionRequest | None = None,
guidance_snapshots: dict[str, Any] | None = None,
) -> tuple[ChatCompletionRequest, ToolParser | None, dict[str, Any], Any, ChatParams]:
"""Validate request and build arguments for template rendering.

Expand Down Expand Up @@ -457,6 +481,22 @@ def _prepare_request(
tool_parser = tool_parser_class(tokenizer, request_for_sampling.tools)
request_for_sampling = tool_parser.adjust_request(request_for_sampling)

# Both parsers rewrite the same request in place, tool first and reasoning
# below, so a single before/after diff cannot say which one moved the
# guidance. Reading it here splits that into two attributable diffs and is the
# only reason the caller can apply one precedence rule to a reasoning rewrite
# and a different one to a tool rewrite. Optional and write-only, so the
# existing call sites are unaffected.
if guidance_snapshots is not None:
guidance_snapshots["after_tool_parser"] = deepcopy(
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved
# Same accessor as adjusted_structured_guidance below, deliberately:
# this value is only ever compared against that one, and
# extract_structured_outputs() is a different view.
_guided_decoding_from_structured_outputs(
request_for_sampling.structured_outputs
)
)

# Strip tools from the template when tool_choice=none so the model doesn't
# see them and generate raw XML tool calls in its response.
tool_dicts = (
Expand Down Expand Up @@ -495,6 +535,24 @@ def _prepare_request(
),
)

# Let the reasoning parser adjust the sampling request, mirroring the tool-parser
# path above. Parsers that split thinking from the answer on text markers need
# `skip_special_tokens=False` so those markers survive detokenisation, and they
# declare that through adjust_request(). Without this call the reasoning channel
# only works when the *client* happens to send skip_special_tokens=false.
#
# Gated identically to StreamingPostProcessor's own parser construction below: if
# the sampling params were adjusted for a parser that then does not exist, the
# markers reach the client as visible text.
#
# ReasoningParser.adjust_request is `return request` by default, so this is a no-op
# for parsers that do not need it.
if _reasoning_parser_enabled(reasoning_parser_class, chat_template_kwargs):
request_for_sampling = reasoning_parser_class(
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved
tokenizer,
chat_template_kwargs=chat_template_kwargs,
).adjust_request(request_for_sampling)
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved

# Mistral warns that tokenize=False is unsafe for chat templates.
is_mistral_tokenizer = (
tokenizer.__class__.__name__ == "MistralTokenizer"
Expand Down Expand Up @@ -536,6 +594,7 @@ async def preprocess_chat_request(
tokenizer: TokenizerLike,
renderer: _Renderer,
tool_parser_class: type[ToolParser] | None,
reasoning_parser_class: type[ReasoningParser] | None = None,
exclude_tools_when_tool_choice_none: bool = True,
enable_auto_tool_choice: bool = False,
default_chat_template_kwargs: dict[str, Any] | None = None,
Expand All @@ -557,6 +616,7 @@ async def preprocess_chat_request(
# structured_outputs itself. Reading it afterwards would treat a
# parser-generated constraint as one the caller sent and reject the request.
has_explicit_output_constraint = _has_explicit_output_constraint(validated_request)
guidance_snapshots: dict[str, Any] = {}
(
request_for_sampling,
tool_parser,
Expand All @@ -567,19 +627,30 @@ async def preprocess_chat_request(
request,
tokenizer=tokenizer,
tool_parser_class=tool_parser_class,
reasoning_parser_class=reasoning_parser_class,
exclude_tools_when_tool_choice_none=exclude_tools_when_tool_choice_none,
enable_auto_tool_choice=enable_auto_tool_choice,
default_chat_template_kwargs=default_chat_template_kwargs,
default_thinking_mode=default_thinking_mode,
validated_request=validated_request,
guidance_snapshots=guidance_snapshots,
)

adjusted_structured_guidance = _guided_decoding_from_structured_outputs(
request_for_sampling.structured_outputs
)
# Either parser's adjust_request() may rewrite the caller's constraint into the
# form the model actually speaks -- a reasoning parser converting
# response_format into a structural tag, say. Gating this recompute on
# tool_parser alone dropped a reasoning parser's rewrite on the floor: the
# request carried the adjusted constraint but the pre-adjustment copy was
# forwarded to the engine.
parser_guided_decoding = (
adjusted_structured_guidance
if tool_parser is not None
if (
tool_parser is not None
or _reasoning_parser_enabled(reasoning_parser_class, chat_template_kwargs)
)
and adjusted_structured_guidance != client_structured_guidance
else None
)
Expand Down Expand Up @@ -608,11 +679,41 @@ async def preprocess_chat_request(
"tool_choice forces a tool call and cannot be combined with an "
"explicit guided_* or structured_outputs constraint."
)
guided_decoding = (
assistant_guided_decoding
if assistant_guided_decoding is not None and not is_forced_tool_choice
else tool_guided_decoding
# Which parser moved the guidance, measured rather than inferred. Both run
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved
# inside _prepare_request against the same request -- tool first, reasoning
# second -- so the snapshot taken between them turns one ambiguous diff into
# two attributable ones:
#
# client -> after_tool_parser changed by the tool parser
# after_tool_parser -> final changed by the reasoning parser
#
# This matters because the two rewrites carry different precedence. A tool
# parser's rewrite loses to the caller's explicit constraint -- deliberate,
# pinned by test_assistant_guidance_takes_precedence_over_auto_tool_guidance
# and the response-format-precedence row of TOOL_GUIDANCE_PARITY_CASES. A
# reasoning parser's rewrite has to win, or the request carries the parser's
# structural tag while the engine is handed the pre-adjustment copy, which is
# the bug this change exists to fix.
#
# When both rewrote it there is nothing to arbitrate: the reasoning parser ran
# last, on the already-tool-adjusted request, so the final value IS the
# composition. Forwarding it is just forwarding what the request holds.
# Absent only when no tool parser ran, in which case nothing has touched the
# request yet and the caller's own constraint is the correct baseline.
guidance_after_tool_parser = guidance_snapshots.get(
"after_tool_parser", client_structured_guidance
)
reasoning_rewrote_guidance = (
_reasoning_parser_enabled(reasoning_parser_class, chat_template_kwargs)
and adjusted_structured_guidance != guidance_after_tool_parser
)
guided_decoding: dict[str, Any] | None
if reasoning_rewrote_guidance and not is_forced_tool_choice:
guided_decoding = adjusted_structured_guidance
elif assistant_guided_decoding is not None and not is_forced_tool_choice:
guided_decoding = assistant_guided_decoding
else:
guided_decoding = tool_guided_decoding
# build_tool_call_guided_decoding falls back to Dynamo's generic JSON schema
# when a forced tool choice has no parser-provided grammar. That can happen
# both without a parser and with parsers (for example Hermes) whose
Expand Down Expand Up @@ -681,14 +782,12 @@ def __init__(
# `enable_thinking` is the convention adopted across the modern
# reasoning-capable model families that vLLM supports; templates
# that don't honor it simply leave it unset (no effect here).
thinking_disabled = chat_template_kwargs.get("enable_thinking") is False
self.reasoning_parser = (
reasoning_parser_class(
tokenizer,
chat_template_kwargs=chat_template_kwargs,
)
if reasoning_parser_class
and not thinking_disabled
if _reasoning_parser_enabled(reasoning_parser_class, chat_template_kwargs)
and response_reasoning_ended is not True
else None
)
Expand Down
142 changes: 142 additions & 0 deletions components/src/dynamo/frontend/tests/test_vllm_processor_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,148 @@ async def test_root_thinking_suppresses_deployment_default(self, tokenizer):
assert "thinking_mode" not in result.chat_template_kwargs


class _FakeStructuralTagReasoningParser:
"""A reasoning parser that rewrites the caller's constraint.

Mirrors a real text-grammar parser converting response_format into the
structural tag its model actually speaks.
"""

def __init__(self, tokenizer, chat_template_kwargs=None):
del tokenizer, chat_template_kwargs

def adjust_request(self, request):
request.skip_special_tokens = False
request.structured_outputs = StructuredOutputsParams(
structural_tag='{"format": "reasoning"}'
)
return request


class _FakePassthroughReasoningParser(_FakeStructuralTagReasoningParser):
def adjust_request(self, request):
return request


class TestReasoningParserGuidanceForwarding:
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved
"""A reasoning parser's guidance must be recomputed and forwarded.

Regression: parser_guided_decoding was gated on `tool_parser is not None`, and
build_tool_call_guided_decoding returns early when there is no tool-call
guidance to build. A reasoning parser that rewrote the caller's constraint
therefore never surfaced: the request carried the adjusted constraint while the
engine received the pre-adjustment copy captured before _prepare_request ran.
"""

@staticmethod
def _request(**overrides):
"""A fresh request each call, so no test can perturb another's input."""
return {
"model": MODEL,
"messages": [{"role": "user", "content": "Hello"}],
"response_format": {"type": "json_object"},
**overrides,
}

@staticmethod
def _renderer():
return SimpleNamespace(
render_messages_async=AsyncMock(
return_value=(None, {"prompt_token_ids": [1]})
)
)

async def _preprocess(self, tokenizer, *, request=None, **kwargs):
kwargs.setdefault("tool_parser_class", None)
return await prepost_module.preprocess_chat_request(
request if request is not None else self._request(),
tokenizer=tokenizer,
renderer=self._renderer(),
**kwargs,
)

@pytest.mark.asyncio
async def test_rewrite_is_forwarded_without_a_tool_parser(self, tokenizer):
"""The whole point: no tool parser anywhere in the request."""
result = await self._preprocess(
tokenizer,
reasoning_parser_class=_FakeStructuralTagReasoningParser,
)

assert result.guided_decoding == {"structural_tag": '{"format": "reasoning"}'}
# The pre-adjustment copy must not win.
assert result.guided_decoding != {"json": {"type": "object"}}

@pytest.mark.asyncio
async def test_passthrough_parser_keeps_the_caller_constraint(self, tokenizer):
"""adjust_request that changes nothing must not disturb the caller."""
result = await self._preprocess(
tokenizer,
reasoning_parser_class=_FakePassthroughReasoningParser,
)

assert result.guided_decoding == {"json": {"type": "object"}}

@pytest.mark.asyncio
async def test_no_reasoning_parser_keeps_the_caller_constraint(self, tokenizer):
result = await self._preprocess(tokenizer, reasoning_parser_class=None)

assert result.guided_decoding == {"json": {"type": "object"}}

@pytest.mark.asyncio
async def test_thinking_disabled_does_not_recompute(self, tokenizer):
"""The gate is off, so no parser runs and nothing may be recomputed.

Recomputing here would forward a constraint from a parser that the
postprocessor will not build.
"""
result = await self._preprocess(
tokenizer,
request=self._request(chat_template_kwargs={"enable_thinking": False}),
reasoning_parser_class=_FakeStructuralTagReasoningParser,
)

assert result.guided_decoding == {"json": {"type": "object"}}
assert result.request_for_sampling.skip_special_tokens is not False

@pytest.mark.asyncio
async def test_rewrite_is_forwarded_with_a_tool_parser_also_active(self, tokenizer):
"""Attribution is measured, so a tool parser being present is irrelevant.

Both parsers run against the same request inside _prepare_request. The
snapshot taken between them shows the tool parser left the guidance alone
and the reasoning parser rewrote it, so the rewrite is attributable and
wins -- exactly as it does with no tool parser at all.
"""
result = await self._preprocess(
tokenizer,
request=self._request(tools=TOOL_REQUEST["tools"], tool_choice="auto"),
tool_parser_class=_FakePassthroughToolParser,
reasoning_parser_class=_FakeStructuralTagReasoningParser,
)

assert result.guided_decoding == {"structural_tag": '{"format": "reasoning"}'}

@pytest.mark.asyncio
async def test_tool_parser_rewrite_still_loses_to_the_caller(self, tokenizer):
"""The other half: tool-path precedence is deliberately unchanged.

Same measurement, opposite attribution -- the tool parser moved the
guidance and the reasoning parser did not -- so the caller's explicit
constraint still wins, as pinned by
test_assistant_guidance_takes_precedence_over_auto_tool_guidance and the
response-format-precedence row of TOOL_GUIDANCE_PARITY_CASES.
"""
result = await self._preprocess(
tokenizer,
request=self._request(tools=TOOL_REQUEST["tools"], tool_choice="auto"),
tool_parser_class=_FakeAdjustRequestGrammarToolParser,
reasoning_parser_class=_FakePassthroughReasoningParser,
)

assert result.guided_decoding == {"json": {"type": "object"}}


class TestToolCallGuidedDecoding:
def _request(self, tokenizer, **overrides):
raw_request = {**TOOL_REQUEST, "tool_choice": "auto", **overrides}
Expand Down
1 change: 1 addition & 0 deletions components/src/dynamo/frontend/vllm_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ async def _generator_inner(
tokenizer=self.tokenizer,
renderer=self.input_processor.renderer,
tool_parser_class=self.tool_parser_class,
reasoning_parser_class=self.reasoning_parser_class,
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved
exclude_tools_when_tool_choice_none=self.exclude_tools_when_tool_choice_none,
enable_auto_tool_choice=self.enable_auto_tool_choice,
default_chat_template_kwargs=self.default_chat_template_kwargs,
Expand Down
Loading
Loading