Repository navigation
fix(sidecar): publish structural tags from the SGLang sidecar by default - #16494
Conversation
✅ Dynamo PR CI passed — run 39320042524 (attempt 3) on
|
|
End-to-end check (Qwen3.6-35B-A3B, vLLM sidecar, tool-calling suites):
|
harryskim
left a comment
There was a problem hiding this comment.
Doc-only review. The doc edits match the new parse_structural_tag behavior. Inline comments cover the TRT-LLM workaround conflict, where the note sits, and a sidecar opt-out example. A few smaller nits on lines this PR doesn't touch in structural-tag.md:
- L115
tool_arguments_any_order: says it requires XGrammar >= 0.2.3, but per the PR description sidecars don't check the engine's XGrammar version. Worth noting that sidecars don't validate it at startup. - L114
reasoning_boundary: the SGLang sidecar doesn't publishtool_call_structural_tag_reasoning_gate, soautoalways resolves tostructural_tagthere. Optional to document, but it would answer the "why doesn'tbackendkick in" question. - L122: the Rust parser also accepts
1/0,yes/no, andon/off. Fine to leave undocumented unless Python accepts them too, in which case listing them keeps the two in sync.
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>
The in-process SGLang worker publishes two runtime keys that the native Rust sidecar left out. The frontend makes both decisions; the sidecar now only reports the engine facts it reads from GetServerInfo. - tool_call_structural_tag_reasoning_gate: the frontend already sends require_reasoning=true, so SGLang holds the grammar until </think>. Without the key the frontend also put an AnyText...</think> prefix in the structural tag. After the real </think> the grammar was still in its reasoning state, so Kimi K2 forced tool calls ran twice and leaked tool markers into content. Published when SGLang reports a reasoning_parser and skip_tokenizer_init is off, the same predicate as publish_sglang_structural_tag_reasoning_policy. - token_budget: without it, a prompt past the context window reached SGLang, which returns INTERNAL, and the client got 500 instead of 400. Published from the existing combined_token_limit (context minus the EAGLE reserve) and allow_auto_truncate, matching _get_token_budget. SGLang always validates total tokens, so both rejection flags follow allow_auto_truncate. Left unpublished when either input is unknown. The SGLang mocker now reports allow_auto_truncate=false, SGLang's default, so the testkit registration check covers token_budget end to end. Signed-off-by: Alec Flowers <aflowers@nvidia.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5f04240 to
4cd8400
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Alec Flowers <aflowers@nvidia.com>
…cument sidecar opt-out The mocker example flattens CommonArgs but dropped --dyn-structural-tag. Docs: add the SGLang sidecar opt-out, move the sidecar note under Preserve Previous Behavior, and replace the legacy TensorRT-LLM workaround flag, which the sidecars reject, with --dyn-structural-tag false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Alec Flowers <aflowers@nvidia.com>
…ool_opt The hand-rolled true/false match tripped dynamo-truthy's no_bool_parse_forks lint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Alec Flowers <aflowers@nvidia.com>
dmitry-tokarev-nv
left a comment
There was a problem hiding this comment.
No P0 or P1 found. This review read head 1af91526552ba71afc7a00121b82e2530f3d9abb.
- [P2]
lib/sidecar/sglang/src/engine.rs:179: no test checks the new SGLang default or the flag parser after6e0c72b0e5. The inline comment has a tested patch. - [P3] PR description: the Validation section names tests that
6e0c72b0e5removed, and "Not included" says that the mocker example registers without structural tags. Details below.
[P3] Description text that does not match this head.
- Validation names "the new
structural_tag_flag_overrides_the_backend_default" and "structural_tag_json_keeps_always_scope_unless_set". Commit6e0c72b0e5removed both tests. - Validation names "the SGLang
worker_publishes_structural_tag_policy", "the vLLMstructural_tags_are_off_by_default", and "the TensorRT-LLM registration test (off by default, bare flag enables)". Commit6e0c72b0e5removed all three. - Validation says that
assert_registrationchecks "structural_tag.scope = always" on the SGLang card and that "the vLLM card has no structural tag". On this head, neither check looks atstructural_tag. The SGLang check assertstoken_budgetand that the gate key is absent. - Not included says that the mocker example "still registers without structural tags". Commit
7060b1314fwires the flag there. With--dyn-structural-tag, the mocker example registers scopealwayswith schemaauto. - Summary says that the frontend "hides the incomplete call". In the end-to-end run below (hermes parser), the head does not hide it. The client gets one tool call with
argumentsclosed to{"city": "Par"}andfinish_reasonlength.
The flag: Rust matches the Python flag in 58 of 63 cases. The table lists the 5 differences.
This review ran CommonArgs and the Python parser from runtime_args.py on this head with the same 63 cases. The values were unset, a bare flag, the true and false words, mixed case, and padded spaces. They also included an empty value, each allowed JSON field, an unknown field, {"scope":null}, malformed JSON, [], null, and "true". Each value ran as the flag and as DYN_STRUCTURAL_TAG. Five more cases set both.
| Input | Rust CommonArgs |
Python flag |
|---|---|---|
true values (true, 1, yes, on, any case, padded) |
scope always, schema auto |
same |
false values (false, 0, no, off, any case) |
none | same |
{} and each allowed field |
scope always unless set, the field as given |
same |
unknown field, {"scope":null}, malformed JSON, [], null, "true" |
startup error | startup error |
empty value, as the flag or as DYN_STRUCTURAL_TAG |
startup error | startup error |
| unset | sidecar default (SGLang on, vLLM and TensorRT-LLM off) | on |
{"tool_arguments_any_order":true} |
accepted, no XGrammar check | XGrammar version check |
bad DYN_STRUCTURAL_TAG with a valid flag (3 cases) |
the flag wins | startup error |
The unset default and the XGrammar check are described in the PR. The last row is Rust being more lenient. This review found no other difference.
Registered `structural_tag` per sidecar on this head.
| Binary | unset | --dyn-structural-tag or true |
false |
|---|---|---|---|
dynamo-sglang-sidecar |
scope always, schema auto |
scope always, schema auto |
none |
dynamo-vllm-sidecar |
none | scope always, schema auto |
none |
dynamo-trtllm-sidecar |
none | scope always, schema auto |
none |
| mocker example | none | scope always, schema auto |
none |
At the merge base, every sidecar registered none, because the flag did not exist.
End to end: the head fixes the truncated forced tool call and the context overflow.
Setup: the testkit HTTP frontend, a real dynamo-sglang-sidecar process, and the SGLang mocker, with tool_call_parser set to hermes in GetServerInfo. The mocker held each request and read the guided-decoding constraint that the sidecar sent. It then replied with tokens that fit the constraint. The reply stops at max_tokens with finish_reason length.
| Request | Merge base 2141a9690d |
Head |
|---|---|---|
tool_choice required, streamed |
Constraint json_schema. Deltas {"city": and "Par, so arguments stay unterminated. length. |
Constraint structural_tag. One tool call, arguments {"city": "Par"}. length. |
tool_choice required, not streamed |
json_schema. No tool call and no content. length. |
structural_tag. One tool call, arguments {"city": "Par"}. length. |
| named tool, streamed | same as required, streamed | same as required, streamed |
| named tool, not streamed | same as required, not streamed | same as required, not streamed |
| prompt of 32778 tokens, context 32768, not streamed | 500 with the mocker set to return INTERNAL, as SGLang does. 400 with the mocker default INVALID_ARGUMENT. The request reached the engine. |
400 context_length, limit 32768. The request did not reach the engine. |
| same prompt, streamed | HTTP 200 with an in-band error event (500 or 400) | 400 |
SGLang runtime data compared with the in-process worker: the same values in the common case, and the differences are listed.
Measured on the sidecar card, through the testkit with GetServerInfo patches:
GetServerInfo |
tool_call_structural_tag_reasoning_gate |
token_budget |
|---|---|---|
| no reasoning parser | absent | {"combined_limit":32768,"reject_prompt_overflow":true,"reject_total_overflow":true} |
reasoning_parser set |
true |
same |
reasoning_parser set, skip_tokenizer_init: true |
absent | same |
reasoning_parser set, skip_tokenizer_init: false |
true |
same |
allow_auto_truncate: true |
absent | {"combined_limit":32768,"reject_prompt_overflow":false,"reject_total_overflow":false} |
The in-process worker (components/src/dynamo/sglang/register.py, read, not run) publishes the same values in these cases. Differences found by reading the code:
- The in-process gate has four more conditions. It needs a tokenizer and
require_reasoningsupport in the engine. Itsthink_end_tokenmust encode to at least one token. The handler must not be multimodal or diffusion-LM. The sidecar checks onlyreasoning_parserandskip_tokenizer_init. SGLang 0.5.22 carriesrequire_reasoningon its native gRPCGenerateRequest. - In-process
reject_total_overflowisvalidate_total_tokens and not allow_auto_truncate. The sidecar usesnot allow_auto_truncate. SGLang 0.5.22 setsvalidate_total_tokens = Trueand never clears it, so the values match there. - When the EAGLE reserve is larger than the context, in-process publishes
combined_limit0, and the sidecar publishes notoken_budget. - If
GetServerInfohas noallow_auto_truncate, the sidecar publishes notoken_budget. The same is true for adaptive speculation. The in-process worker always publishes it.
This review did not measure a user-visible effect of these differences.
Earlier review threads: each point is addressed on this head.
All 7 threads are resolved. This review agrees with each point and found each one addressed:
- The reasoning gate key (KrishnanPrash): published since
4cd8400eeb. The deliberate break of theskip_tokenizer_initcondition failsreasoning_gate_follows_the_engine_reasoning_parser. - TensorRT-LLM tags without xgrammar (KrishnanPrash): the TensorRT-LLM sidecar now registers none by default.
- The mocker example drops the flag (KrishnanPrash): fixed in
7060b1314f, measured above. - The four docs threads (harryskim): the note moved under Preserve Previous Behavior, the TensorRT-LLM workaround uses
--dyn-structural-tag false, the SGLang sidecar opt-out is listed, andruntime-configuration.mdxnames the sidecar defaults. Each statement matches the measured defaults. - The three notes in the harryskim review body: the description lists the XGrammar check for
tool_arguments_any_orderunder "Not included". This review measured that the sidecar flag accepts the field without a check. The note about the missing gate key no longer applies. Python also accepts1,0,yes,no,onandoff(table above), so the two parsers agree.
Tests, merge with main, and CI.
- Tests on this head (macOS): backend-common 161, SGLang sidecar 121, vLLM sidecar 132, TensorRT-LLM sidecar 51 (2 ignored), mocker example 20, SGLang mocker 11, router integration 22, mocker integration 25. Local router integration runs were flaky: 2 of 4 full runs on this head timed out in 1 to 5 tests, at different steps and for vLLM too. The merge base had no timeout in its one run. CI
rust-tests (.)passed all 22 router and 25 mocker integration tests on Linux, on a merge of this head. - Merge with
main649bc1469578aeeecc30099ce706185e917479d1: no conflict. The same tests pass on the merge, except one local router timeout of the same kind. - CI on this head:
pre-merge-status-check,deploy-status-check,rust-tests (.),rust-clippy (.)andpre-commitpassed. The docs checks, the SGLang and vLLM runtime tests, and the TensorRT-LLM arm64 runtime test also passed.trtllm-runtime / Test cuda13.1, amd64anddynamo-runtime / test / parallel cuda13.0, arm64failed because their pytest output stopped at the same second (21:05:38Z) with no test failure.dynamo-runtime / rust-gpuwas cancelled. These three jobs makebackend-status-checkanddynamo-status-checkfail. None of these jobs runs the sidecar code that this PR changes, so this review does not relate them to the diff. Require an issue linkfailed because the PR names no issue. 44 checks were skipped (deploy, snapshot, dev-image and Helm lanes). A skipped check is not a pass.
dmitry-tokarev-nv
left a comment
There was a problem hiding this comment.
No P0 or P1 found at head 1af91526552ba71afc7a00121b82e2530f3d9abb.
This review compared the flag with the Python flag on 63 inputs, and read the registered structural_tag of each sidecar. It also sent a truncated forced tool call and an overlong prompt through the frontend, the SGLang sidecar and the SGLang mocker. It did this at the merge base and at this head. The head fixes the unterminated arguments and returns 400 for the overlong prompt.
Open items:
- [P2]
lib/sidecar/sglang/src/engine.rs:179: no test checks the new SGLang default or the flag parser after6e0c72b0e5. The inline comment has a tested patch. - [P3] The PR description names tests that
6e0c72b0e5removed, and says that the mocker example registers without structural tags.
CI: dynamo-runtime / rust-gpu was cancelled on this head, and two runtime test jobs stopped mid-run at 21:05:38Z without a test failure.
…ult (ai-dynamo#16494) Signed-off-by: Alec Flowers <aflowers@nvidia.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
….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>
Summary
The in-process
dynamo.vllm,dynamo.sglang, anddynamo.trtllmworkers publishstructural_tag = {scope: always, schema: auto}by default (--dyn-structural-tag,#13654). The native Rust sidecars never set
WorkerConfig.structural_tag, so theyregistered with structural tags disabled.
With tags disabled,
tool_choice=requiredor a named tool falls back to the legacyguided-JSON grammar. Guided tool streaming (#12576, on by default) then releases
argument fragments as they arrive. When
max_tokenstruncates the output, theclient receives a tool call whose
argumentsare unterminated JSON, withfinish_reason=length. The in-process worker uses a structural tag instead, so thefrontend buffers the parser-native markup and hides the incomplete call.
This PR:
--dyn-structural-tag/DYN_STRUCTURAL_TAGtoCommonArgsindynamo-backend-common, with the Python flag's values:truegives scopealwayswith schemaauto,falsedisables tags, and a JSON object setsindividual fields (scope defaults to
always; unknown fields are rejected).Unset, each sidecar applies its own default through
CommonArgs::structural_tag.([Bugfix][Rust Frontend] Use xgrammar for gRPC structured outputs vllm-project/vllm#61892, approved). Today it sends every structured output to
guidance, which rejects them (
KeyError: 'triggers'), so every tool requestwould return 500.
tag formats, for example GLM ("style must be json, qwen_xml, …") and Kimi K2
("Only the last element in a sequence can be unlimited");
[Bug]: Upgrade XGrammar: qwen_xml compilation errors and tool-call loops recover on 0.2.7 NVIDIA/TensorRT-LLM#19913 tracks the upgrade.
tool_call_structural_tag_reasoning_gateandtoken_budget, matching the in-process SGLang worker:require_reasoning=true, so SGLangholds the grammar until
</think>. Without the key the frontend also put anAnyText…</think>prefix in the structural tag. After the real</think>thegrammar was still in its reasoning state, so Kimi K2 forced tool calls ran twice
and leaked tool markers into
content. With tags on by default this would hitevery reasoning model, so the key ships with the default. Published when SGLang
reports a
reasoning_parserandskip_tokenizer_initis off.which returns
INTERNAL, and the client got 500 instead of 400.E2E evidence
From the Dynamo E2E tool-calling harness on GB200, sidecars on Dynamo
1.6.0.dev20261016 against the in-process nightly:
customer_truncated_tool_markup_hidden(stream) fails on every vLLM and SGLangsidecar run with unterminated
arguments, and passes in-process. On 2026-10-15it passed on the vLLM sidecar with this change plus vllm#61892.
contenton named/required tool choice, and one BFCL request that returned 500for a 10354-token prompt against a 10240-token context.
Not included
--dyn-enable-structural-tag/--dyn-structural-tag-scope/--dyn-structural-tag-schemaflags or env vars; they are deprecated on thePython side.
tool_arguments_any_order. The engine runs in aseparate process, and the frontend already requires parsers v2 for that option.
backend-commonmocker example also flattensCommonArgsbut stillregisters without structural tags.
per sidecar once their engines are ready.
Validation
cargo test -p dynamo-backend-common --lib args: pass, including the newstructural_tag_flag_overrides_the_backend_default(unset, bare flag,false,DYN_STRUCTURAL_TAG=off|on, against a default-on and a default-off backend) andstructural_tag_json_keeps_always_scope_unless_set.cargo test -p dynamo-sglang-sidecar -p dynamo-vllm-sidecar -p dynamo-trtllm-sidecar -p dynamo-sglang-mocker:all pass (TensorRT-LLM 51 + 2 ignored). New or updated: the SGLang
worker_publishes_structural_tag_policy(on by default,falsedisables), the vLLMstructural_tags_are_off_by_default, the TensorRT-LLM registration test (off bydefault, bare flag enables), and the SGLang reasoning-gate and token-budget tests.
CUDA_VISIBLE_DEVICES= HF_HUB_OFFLINE=1 cargo test -p dynamo-sidecar-testkit --tests -- --test-threads=2:1 + 22 router + 25 mocker integration pass.
assert_registrationchecks that theSGLang card carries
structural_tag.scope = always,token_budgetand the gate,and that the vLLM card has no structural tag.
cargo clippy --all-targets -- -D warningson the six changed crates; rustfmtthrough pre-commit.
sidecar built from this branch.
🤖 Generated with Claude Code