Skip to content

[Bugfix][Rust Frontend] Use xgrammar for gRPC structured outputs - #61892

Merged
BugenZhao merged 5 commits into
vllm-project:mainfrom
alec-flowers:agent/grpc-xgrammar-backend
Oct 18, 2026
Merged

BugenZhao merged 5 commits into
vllm-project:mainfrom
alec-flowers:agent/grpc-xgrammar-backend

Conversation

@alec-flowers

@alec-flowers alec-flowers commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Overview

The Rust frontend's gRPC server lowers every structured-output constraint to the guidance backend (#44729). Guidance only parses the legacy triggers/structures structural tag (#55152), so gRPC clients that send xgrammar-format structural tags for tool calling get INTERNAL on every request. This PR makes the gRPC path use xgrammar for every constraint, as Python's auto does, teaches the xgrammar backend to compile choice itself, and applies Python's xgrammar schema check in the Rust frontend's shared request validation.

Claims

  • gRPC structural_tag constraints in the current {"type": "structural_tag", "format": ...} shape compile and constrain output instead of failing in EngineCore with KeyError: 'triggers'.
  • All gRPC structured-output requests select one backend, so a gRPC-served engine no longer depends on which request arrives first to pick its backend.
  • The xgrammar backend compiles choice requests with the same EBNF grammar Python's choice_as_grammar produces, so frontends that skip Python request validation can send choice as is. This also fixes Rust HTTP choice requests failing with grammar is not of valid supported types once xgrammar is the engine's backend.
  • JSON schemas that use keywords xgrammar ignores are rejected when the request's backend is xgrammar, with Python's message, instead of being silently under-enforced. gRPC returns INVALID_ARGUMENT; Rust HTTP would return 400, though no HTTP constraint takes this path yet.
  • Regexes containing NUL are rejected in StructuredOutputsParams::validate, as Python does for every backend. This also applies to the Rust HTTP routes.

Validation

Qwen3.6-35B-A3B on GB200 through the Dynamo vLLM sidecar, which talks to vllm-rs over gRPC and sends xgrammar-format structural tags for tool calls (ai-dynamo/dynamo#16494). Same vLLM nightly in both columns; the right column adds an earlier revision of this commit (xgrammar for every gRPC constraint plus the choice rewrite) to vllm-rs.

Check vLLM main This PR
tool_choice: auto + one tool HTTP 500 (gRPC INTERNAL, KeyError: 'triggers') HTTP 200
Dynamo tool-calling suite (48 cases) not run, every tool request 500s 47/48 (the miss is tool markup inside reasoning, also intermittent on other runtimes)
BFCL sample (50 cases) not run 39/50, all HTTP 200 (other Dynamo runtimes score 38 to 43 on this model)

Tests on the current head:

  • Rust: cargo nextest run -p vllm-engine-core-client -p vllm-text -p vllm-server: 713 passed. unary_generate_invalid_structured_outputs_returns_invalid_argument sends an empty grammar, an empty choice, a NUL regex and an xgrammar-unsupported schema through the real gRPC server against the mock engine and expects INVALID_ARGUMENT for each. With the text-lowering validation removed, the request reaches the engine and the test fails.
  • Python: test_xgrammar_compiles_choice_without_rewrite compiles an unrewritten choice request through get_structured_output_key. It fails without the backend change (grammar is not of valid supported types. (StructuredOutputOptions.CHOICE)) and passes with it. test_backend_xgrammar.py and test_backend_xgrammar_lark.py have the same results with and without this PR.

Details

Root cause. StructuredOutputManager creates one backend from the first structured-output request it sees and compiles every later request with it (vllm/v1/structured_output/__init__.py). The Rust frontend skips Python's SamplingParams validation, which is where _backend is normally chosen, so #44729 set the backend to guidance, which handles choice natively. The guidance backend reads structural tags in the legacy shape only. The format shape that tool-calling clients and the Rust chat path (#46057) produce raises KeyError: 'triggers' in GuidanceBackend.compile_grammar, the request finishes with an error, and the gRPC server returns INTERNAL.

Why every constraint, not only structural tags. With xgrammar for structural tags and guidance for the rest, whichever kind of request reached the engine first would decide the backend, and the other kind would fail. One backend for the whole gRPC path avoids that.

Where the xgrammar checks live. Python's validate_xgrammar_grammar rewrites choice into a grammar (the xgrammar backend has no choice path), and auto keeps schemas that has_xgrammar_unsupported_json_features flags away from xgrammar. The Rust frontend bypasses that validation. Both rules depend on the backend, not on the protocol, so neither goes into the gRPC converter:

  • choice: XgrammarBackend.compile_grammar compiles CHOICE with choice_as_grammar, as [Structured Outputs] Parse Lark grammars natively in the xgrammar backend #58321 did for Lark. Every frontend sends choice unchanged, and the grammar has one implementation.
  • Unsupported schema keywords: has_unsupported_json_features is ported into engine-core-client, and StructuredOutputsParams::validate rejects such schemas when the backend is xgrammar. Text lowering (lower_sampling_params) validates the final constraint for every route, after chat may have replaced the user's constraint with a parser-built output grammar. The error maps to 400 / INVALID_ARGUMENT. The gRPC converter only selects xgrammar and no longer validates on its own.

Python's NUL-in-regex rejection is backend-independent, so it goes into StructuredOutputsParams::validate next to the other early checks.

Tradeoff. Python's auto sends schemas with keywords xgrammar ignores (multipleOf, uniqueItems/contains, unsupported string formats, pattern with length bounds, some propertyNames/patternProperties combinations, multi-branch allOf) to guidance. EngineCore holds one backend, so the gRPC path cannot do that per request. It now rejects them with INVALID_ARGUMENT, as Python does when the backend is xgrammar, rather than accepting them and enforcing less than the schema says. gRPC clients that relied on guidance for those keywords will see the error.

Not changed. Python also parses grammars, regexes and schemas with xgrammar up front; on the gRPC path an invalid constraint still fails at EngineCore compile time, as it did under guidance. The Rust frontend has no JSON nesting cap (#60036) on any route, before or after this PR. Python's auto sends Lark grammars for Tekken Mistral tokenizers to guidance; on the gRPC path they now use xgrammar.

Not a duplicate.

Limitation / follow-up. The Rust HTTP routes still lower native constraints to guidance while parser-built output grammars use xgrammar (#46057, #60781). A server that takes both kinds of traffic over HTTP is still order-dependent. With the xgrammar choice path, HTTP choice no longer fails when xgrammar is chosen first, but parser-built structural tags still fail when guidance is chosen first. This PR leaves those routes' backend choice unchanged; the follow-up is a single operator-selected backend for every Rust route, defaulting to xgrammar.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

AI assistance (Claude) was used to investigate, write and test this change. I reviewed every changed line and ran the Rust unit tests, clippy and pre-commit on it.

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

EngineCore keeps the first structured-output backend it sees, and the
gRPC path lowered every constraint to guidance. Guidance cannot compile
xgrammar-format structural tags (it reads the legacy triggers/structures
shape), so tool-call structural tags from gRPC clients failed with an
engine error.

Select xgrammar for every gRPC constraint, matching Python's preferred
backend, and apply the request checks Python performs for xgrammar:

- lower choice into an EBNF grammar; the xgrammar backend has no choice
  path
- reject JSON schemas with features xgrammar ignores (multi-branch
  allOf, uniqueItems, multipleOf, ...), which guidance used to enforce

Also reject regexes containing NUL in StructuredOutputsParams::validate,
as Python does for every backend before any grammar conversion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Alec Flowers <aflowers@nvidia.com>
…te xgrammar schemas in shared lowering

The xgrammar backend had no `choice` path: Python request validation
rewrites `choice` into a grammar, but frontends that skip it (the Rust
frontend) sent it as is and failed to compile. Compile it in the backend
with the same `choice_as_grammar`, and drop the Rust port of the rewrite.

Move the xgrammar unsupported-JSON-schema check from the gRPC converter
into `StructuredOutputsParams::validate()`, keyed on the selected
backend, and validate the final constraint in text lowering so every
route rejects such schemas with 400 / INVALID_ARGUMENT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC3GtVSLAo2RkJbs4Zi88h
Signed-off-by: Bugen Zhao <i@bugenzhao.com>

@BugenZhao BugenZhao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @alec-flowers I'll add some commits to address my comments

Comment thread rust/src/server/src/grpc/xgrammar.rs Resolved
Comment thread rust/src/server/src/grpc/convert.rs Resolved
Text lowering now validates the final structured-output constraint for
every route, so drop the duplicate `validate()` in the gRPC converter and
move its rejection tests to the gRPC service test, which sends each
request through the real server against the mock engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC3GtVSLAo2RkJbs4Zi88h
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
@BugenZhao

Copy link
Copy Markdown
Member

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #95727 for commit 485a752af90a.

ℹ️ This PR is 136 commits behind upstream main. Merged upstream main into this branch as 485a752af90a before starting CI. Pull the branch before pushing more changes.

@BugenZhao

Copy link
Copy Markdown
Member

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #95738 for commit ffc91f253112.

ℹ️ This PR is 4 commits behind upstream main. Merged upstream main into this branch as ffc91f253112 before starting CI. Pull the branch before pushing more changes.

@BugenZhao
BugenZhao merged commit e0adacc into vllm-project:main Oct 18, 2026
108 checks passed
alec-flowers added a commit to ai-dynamo/dynamo that referenced this pull request Oct 18, 2026
In-process vLLM, SGLang, and TensorRT-LLM workers publish structural tags
with scope `always` and schema `auto` by default. The Rust sidecars never
set `WorkerConfig.structural_tag`, so forced tool choices fell back to
guided JSON, and a truncated tool call streamed unterminated argument JSON
instead of being hidden.

Add `--dyn-structural-tag` / `DYN_STRUCTURAL_TAG` to `CommonArgs` with the
Python flag's values (true, false, or a JSON object whose scope defaults to
`always`). Unset, each sidecar applies its own default through
`CommonArgs::structural_tag`:

- SGLang: on. SGLang compiles Dynamo's structural tags natively.
- vLLM: off until vLLM's gRPC server compiles structural tags with xgrammar
  (vllm-project/vllm#61892); its guidance backend rejects them.
- TensorRT-LLM: off. The xgrammar version it pins rejects several tool-call
  tag formats (NVIDIA/TensorRT-LLM#19913).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Alec Flowers <aflowers@nvidia.com>
michael-gpu pushed a commit to practical-inference/vllm-ampere-extended that referenced this pull request Oct 20, 2026
…m-project#61892)

Signed-off-by: Alec Flowers <aflowers@nvidia.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Bugen Zhao <i@bugenzhao.com>
Co-authored-by: vllm-agent <claw@inferact.ai>
@arpera arpera moved this from Unsorted queue to Done in Structured Output (arpera) Oct 20, 2026
JulienDarve added a commit to ai-dynamo/dynamo that referenced this pull request Nov 2, 2026
vllm-proto 0.6 also arrived in vLLM 0.33.0, but vLLM main builds from
2026-10-14 to 2026-10-18 report it without vllm-project/vllm#61892, so the
sidecar would publish tags those engines reject. Read the vLLM release from
ServerInfo.engine_version instead and default tags on from 0.33.0; its release
candidates and development builds stay off.

Signed-off-by: Julien Darve <jdarve@NVIDIA.com>
JulienDarve added a commit to ai-dynamo/dynamo that referenced this pull request Nov 3, 2026
….33+

The vLLM sidecar now defaults structural tags on when vLLM reports version
0.33 or later, as the in-process vLLM worker and the SGLang sidecar (#16494)
already do. vLLM 0.33.0 is the first release whose gRPC server compiles these
tags with xgrammar (vllm-project/vllm#61892); older servers send them to
guidance, which rejects them, so the default stays off there. The version comes
from ServerInfo.engine_version and is compared with pep440_rs.

Folded in from #17866.

Signed-off-by: Julien Darve <jdarve@NVIDIA.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working rust structured-output

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants