Repository navigation
[Bugfix][Rust Frontend] Use xgrammar for gRPC structured outputs - #61892
Merged
BugenZhao merged 5 commits intoOct 18, 2026
Merged
Conversation
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>
alec-flowers
force-pushed
the
agent/grpc-xgrammar-backend
branch
from
October 16, 2026 04:08
8c90383 to
e7a9213
Compare
This was referenced Oct 18, 2026
…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
approved these changes
Oct 18, 2026
BugenZhao
left a comment
Member
There was a problem hiding this comment.
Thanks @alec-flowers I'll add some commits to address my comments
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
requested review from
aarnphm,
arpera,
benchislett and
mgoin
as code owners
October 18, 2026 14:57
Member
|
/ci run |
|
✅ Triggered Buildkite CI #95727 for commit ℹ️ This PR is 136 commits behind upstream |
Member
|
/ci run |
|
✅ Triggered Buildkite CI #95738 for commit ℹ️ This PR is 4 commits behind upstream |
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>
6 tasks done
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>
Closed
1 task done
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>
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
The Rust frontend's gRPC server lowers every structured-output constraint to the guidance backend (#44729). Guidance only parses the legacy
triggers/structuresstructural tag (#55152), so gRPC clients that send xgrammar-format structural tags for tool calling getINTERNALon every request. This PR makes the gRPC path use xgrammar for every constraint, as Python'sautodoes, teaches the xgrammar backend to compilechoiceitself, and applies Python's xgrammar schema check in the Rust frontend's shared request validation.Claims
structural_tagconstraints in the current{"type": "structural_tag", "format": ...}shape compile and constrain output instead of failing in EngineCore withKeyError: 'triggers'.choicerequests with the same EBNF grammar Python'schoice_as_grammarproduces, so frontends that skip Python request validation can sendchoiceas is. This also fixes Rust HTTPchoicerequests failing withgrammar is not of valid supported typesonce xgrammar is the engine's backend.INVALID_ARGUMENT; Rust HTTP would return 400, though no HTTP constraint takes this path yet.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-rsover 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 thechoicerewrite) tovllm-rs.tool_choice: auto+ one toolINTERNAL,KeyError: 'triggers')Tests on the current head:
cargo nextest run -p vllm-engine-core-client -p vllm-text -p vllm-server: 713 passed.unary_generate_invalid_structured_outputs_returns_invalid_argumentsends an empty grammar, an empty choice, a NUL regex and an xgrammar-unsupported schema through the real gRPC server against the mock engine and expectsINVALID_ARGUMENTfor each. With the text-lowering validation removed, the request reaches the engine and the test fails.test_xgrammar_compiles_choice_without_rewritecompiles an unrewrittenchoicerequest throughget_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.pyandtest_backend_xgrammar_lark.pyhave the same results with and without this PR.Details
Root cause.
StructuredOutputManagercreates 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'sSamplingParamsvalidation, which is where_backendis normally chosen, so #44729 set the backend to guidance, which handleschoicenatively. The guidance backend reads structural tags in the legacy shape only. Theformatshape that tool-calling clients and the Rust chat path (#46057) produce raisesKeyError: 'triggers'inGuidanceBackend.compile_grammar, the request finishes with an error, and the gRPC server returnsINTERNAL.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_grammarrewriteschoiceinto a grammar (the xgrammar backend has nochoicepath), andautokeeps schemas thathas_xgrammar_unsupported_json_featuresflags 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_grammarcompilesCHOICEwithchoice_as_grammar, as [Structured Outputs] Parse Lark grammars natively in the xgrammar backend #58321 did for Lark. Every frontend sendschoiceunchanged, and the grammar has one implementation.has_unsupported_json_featuresis ported intoengine-core-client, andStructuredOutputsParams::validaterejects 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::validatenext to the other early checks.Tradeoff. Python's
autosends schemas with keywords xgrammar ignores (multipleOf,uniqueItems/contains, unsupported stringformats,patternwith length bounds, somepropertyNames/patternPropertiescombinations, multi-branchallOf) to guidance. EngineCore holds one backend, so the gRPC path cannot do that per request. It now rejects them withINVALID_ARGUMENT, as Python does when the backend isxgrammar, 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
autosends Lark grammars for Tekken Mistral tokenizers to guidance; on the gRPC path they now use xgrammar.Not a duplicate.
choicepath to the Python xgrammar backend. Its author noted that the engine supporting several backends at once may be the right long-term direction. This PR is narrower: it changes the gRPC backend choice, adds the samechoicepath to the Python xgrammar backend, and adds the backend-keyed schema check and the NUL check to shared Rust validation, with no CLI changes. It fixes a hard failure now and does not get in the way of engine-side multi-backend support.formatshapes (triggered_tags,tags_with_separator,any_text) and rejects the rest. xgrammar compiles the full structural-tag format, and the Rust chat path already sends these tags withbackend = xgrammar([Rust Frontend] Integratexgrammar-structural-tagforstrictandrequiredtool calling #46057), so the gRPC path now matches it. The two PRs do not conflict.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
choicepath, HTTPchoiceno 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-checklistskill. (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