Skip to content

[Bugfix][Structured Output] Mask grammar-invalid implicit reasoning ends - #61574

Open
vMaroon wants to merge 2 commits into
vllm-project:mainfrom
vMaroon:so-implicit-reasoning-end-mask
Open

vMaroon wants to merge 2 commits into
vllm-project:mainfrom
vMaroon:so-implicit-reasoning-end-mask

Conversation

@vMaroon

@vMaroon vMaroon commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Overview

With an engine-based reasoning parser, a structured-output request fails with "grammar rejected tokens" and HTTP 500 when the model samples an implicit reasoning end, such as GLM's <tool_call>, that the request's grammar cannot start with. Reasoning rows of the bitmask are unconstrained, so the token can be sampled, and since #59627 it is fed to the grammar. This masks those tokens while the request reasons. Implicit ends the grammar accepts stay allowed.

Related: #60722 (RFC; this is the "remaining issue" it describes, using its first option restricted to grammar-rejected tokens), #59627 (feeds the implicit end to the grammar), #59608 (required/named tool calls, which must keep <tool_call> allowed), #61456 (open; also edits _fill_bitmasks).

Claims

  • A request whose grammar rejects an implicit reasoning end as its first token (a JSON schema, tool_choice="none" with response_format) can no longer sample that token while reasoning, so it no longer ends in "grammar rejected tokens".
  • Requests whose grammar accepts the implicit end are unchanged: tool_choice required, named, and auto with tools (with or without response_format).
  • The explicit end (</think>) and every other token stay allowed while reasoning. Draft and bonus rows under speculative decoding are masked the same way.

Validation

Unit, CPU. VLLM_TARGET_DEVICE=cpu, xgrammar and guidance:

python -m pytest tests/v1/structured_output/test_structured_output_manager.py \
  tests/parser/engine/test_parser_engine.py tests/v1/spec_decode/test_mtp_structured_output.py
# main source + these tests: 181 passed, 8 failed, 1 xfailed
# this branch:               189 passed, 1 xfailed

The 8 that fail on main: test_real_flow[implicit_end_rejected-*] (pattern RRUUU; main gives UUUUU), test_reasoning_rows_mask_grammar_invalid_implicit_end[json-*], test_parallel_fill_masks_grammar_invalid_implicit_end[*], and the two TestReasoningEndTokenIds cases for the new property. test_reasoning_rows_mask_grammar_invalid_implicit_end[regex_starts_with_marker-*] passes on both: a grammar that starts with the marker keeps it allowed and consumes it.

Wider run on this branch: tests/v1/structured_output, tests/parser (without tests/parser/cohere, which needs cohere_melody), tests/reasoning/test_glm4_moe_reasoning_parser.py, tests/v1/worker/test_grammar_invalid_drafts.py, tests/v1/spec_decode/test_mtp_structured_output.py: 6636 passed, 1 xfailed. tests/v1/core/test_async_scheduler.py: 19 passed on both. tests/v1/core/test_scheduler.py: the same 48 multimodal tests fail on main and on this branch, because this kernel-less install cannot inspect the Llava architecture.

Real components, no model. GLM-5.3 tokenizer, --reasoning-parser glm45 (Glm47MoeParserReasoningAdapter), xgrammar, prompt ending in <think>. Bitmask rows while reasoning, then accept_tokens([<tool_call>]) as the scheduler would call it:

constraint main: <tool_call> allowed / masked bits this branch accept_tokens
JSON schema, no spec yes / 0 no / 1 (<tool_call> only; </think> allowed) False
JSON schema, 3 draft rows + bonus yes / 0 in all 4 rows no / 1 in all 4 rows False
required structural tag yes / 0 yes / 0 True
named structural tag yes / 0 yes / 0 True

End to end, CPU server. arm64 CPU image of main 206e45d47, whose four changed files are identical to a2fb48273, with the structured-output backends from a2fb48273 mounted in both arms and this branch's four files in the second. A 2-layer Llama with the GLM-5.3 tokenizer and vocabulary, --reasoning-parser glm45 --tool-call-parser glm47 --enable-auto-tool-choice, xgrammar, greedy, 3 runs per case. logit_bias makes <tool_call> (154843) the top token.

request main this branch
json_schema, bias <tool_call> 500 x3, "grammar rejected tokens [154843]" 200 x3, no <tool_call> in reasoning
json_schema + tools + tool_choice="none", same bias 500 x3 200 x3
json_schema, bias <tool_call> 100, </think> 50, answer tokens 30 500 x3 200 x3, {"answer": "yes"}, finish stop
json_schema + tools + tool_choice="auto", same bias 200 x3, tool call 200 x3, same tool call
tool_choice="required" / named / auto without response_format 200 x3 each, tool call 200 x3 each, same tool call
json_schema, no bias 200 x3 200 x3

Every request that returns 200 on main returns the same response on this branch. The engine log on main has one Unexpected: grammar rejected tokens [154843] per 500 (12 of 12); the branch logs have none.

Raw request and responses (third row)
{"model": "glm-tiny", "max_tokens": 96, "temperature": 0,
 "messages": [{"role": "user", "content": "Is the sky blue? Reply as JSON."}],
 "response_format": {"type": "json_schema", "json_schema": {"name": "a", "schema": {
   "type": "object", "properties": {"answer": {"type": "string", "enum": ["yes", "no"]}},
   "required": ["answer"], "additionalProperties": false}}},
 "logit_bias": {"154843": 100, "154842": 50, "4913": 30, "9214": 30, "788": 30, "330": 30,
                "9689": 30, "9204": 30, "154820": 30}}

main, 3 of 3:

HTTP 500 {"error":{"message":"Internal server error","type":"InternalServerError","param":null,"code":500}}
(EngineCore pid=131) ERROR 10-14 06:31:44 [scheduler.py:2189] Unexpected: grammar rejected tokens [154843] for request chatcmpl-8bf78d42551e682d-8a4ad66a. Terminating request.

this branch, 3 of 3 (fields from the response):

HTTP 200  finish_reason=stop  reasoning="" (no <tool_call>)  content={"answer": "yes"}  tool_calls=[]

Speculative decoding, end to end. logit_bias is rejected with speculative decoding, so these runs use fixed weights instead: layer outputs are zero and the LM head prefers <tool_call>, then the, at every position. ngram, 3 speculative tokens, json_schema, 96 tokens:

main this branch
json_schema 500 x3 200 x3, 96 reasoning tokens, all the
json_schema + tools + tool_choice="none" 500 x3 200 x3
drafts accepted (json_schema only) 207 of 225
required, auto + tools tool calls same tool calls

Each accepted draft position used a masked row; an unmasked draft row would have sampled <tool_call> and failed the request.

CPU harness note

On main the CPU stand-in _compute_slot_mapping_kernel_impl (vllm/utils/cpu_triton_utils.py) takes its arguments in an order that no longer matches ComputeSlotMappingKernel.kernel, so the dispatcher passes slot_mapping as block_size and the first forward fails. These runs mount a copy of that one function, with the kernel's parameter names, in both arms. Separate from this change.

GPU, end to end. @simon-veitner-redhat ran main a2fb482732 against this branch 6d144d6478 on H200 (results): GLM-5.3-Flash-NVFP4, TP2, glm47 tool and reasoning parsers, xgrammar, 8 seeds per row. "Tagged" means a tool result in the conversation quotes GLM's own tags (<tool_call>, <arg_key>, <arg_value>), followed by a json_schema request.

request, no spec decode main this branch
tagged, json_schema, no tools 7/8 500s, 0/8 valid JSON 0 500s, 4/8 valid JSON
tagged, json_schema, tool_choice: "none" 5/8 500s, 3/8 valid JSON 0 500s, 7/8 valid JSON
logit_bias forcing <tool_call>, blocking </think> 8/8 500s 0 500s, empty content
untagged, json_schema 8/8 valid JSON 8/8 valid JSON
tagged, tool_choice: "required" 0 duplicated <tool_call> 0 duplicated <tool_call>
request, MTP 3 tokens, Model Runner V2, async scheduling main this branch
tagged, json_schema, no tools 6/8 500s, 2/8 valid JSON 0 500s, 6/8 valid JSON
tagged, json_schema, tool_choice: "none" 2/8 500s, 6/8 valid JSON 0 500s, 8/8 valid JSON
untagged, json_schema 8/8 valid JSON 8/8 valid JSON
tagged, tool_choice: "required" 0 duplicated <tool_call> 0 duplicated <tool_call>

Main logs one Failed to advance FSM per 500; this branch logs none. The remaining invalid JSON on this branch is a separate frontend issue: the token stream is valid JSON, but the model quotes <tool_call> inside a JSON string and content is cut there. The CPU runs above use the V1 model runner, so both runners are covered.

Motivation. A GLM-5.3 deployment (MTP with 3 tokens, Model Runner V2, xgrammar, glm45) logged 649 "grammar rejected tokens" terminations in 8 hours; 625 of them ended on <tool_call> (154843). A non-streaming request terminated this way returns HTTP 500.

Details

Path of the failure, enable_in_reasoning=False:

  1. A request with a JSON schema is reasoning. _get_constraint_bounds puts constraint_start past every scheduled row, so grammar_bitmask fills each row with the full mask.
  2. The model samples <tool_call>, which Glm47MoeParser treats as an implicit reasoning end: it ends reasoning and starts a tool call.
  3. accept_tokens finds that end and, since [Bugfix][Structured Output] Feed the token that implicitly ends reasoning to the grammar #59627, sets grammar_start to its offset, so <tool_call> is fed to the grammar. A JSON grammar rejects it and accept_tokens returns False.
  4. The scheduler logs "Unexpected: grammar rejected tokens" and finishes the request with FINISHED_ERROR.

The change, in StructuredOutputManager:

  • ParserEngine.implicit_reasoning_end_token_ids: reasoning-end tokens other than the explicit end, the same split find_reasoning_end uses to report an implicit end. Exposed through ParserEngineReasoningAdapter. This only adds a read-only property, so no parser changes behavior.
  • _masked_reasoning_ends: the implicit ends the grammar rejects as its first token (validate_tokens([t]), non-mutating in every backend). The grammar does not advance before reasoning ends, so the set is computed once per request and cached on StructuredOutputRequest.
  • _fill_bitmasks: a row the grammar does not constrain allows every token except those ids. Only rows before constraint_start (including the bonus row while reasoning) get them, in both the serial and the parallel fill. Constrained rows and rows after padding are unchanged.

Tradeoffs and limits:


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 the failure, write the change and tests, and draft this description. I reviewed every changed line and ran the tests above.

An engine-based reasoning parser ends reasoning implicitly on a token
that is also content, such as `<tool_call>`, and vllm-project#59627 feeds that
token to the grammar. Bitmask rows stay unconstrained while the request
reasons, so the model can sample such a token when the grammar cannot
start with it (a JSON schema). accept_tokens then fails and the
scheduler finishes the request with an error.

While a request reasons, mask the implicit reasoning-end tokens its
grammar rejects in its initial state. The explicit end and implicit
ends the grammar accepts (required and named tool calls) stay allowed.
The grammar does not advance before reasoning ends, so the set is
computed once per request.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Maroon Ayoub <mayoub@redhat.com>

@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.

@simon-veitner-redhat

Copy link
Copy Markdown
Contributor

I verified this PR on H200 against the production failure we saw on GLM-5.3 (Failed to advance FSM ... for tokens [..., 154843], which surfaces as HTTP 500).

Why it fixes the bug. Since #59627, an implicit reasoning end (<tool_call>, token 154843 for GLM) is fed to the grammar. The first version of #59627 only did that when validate_tokens accepted the token, but that check was removed during review. Reasoning rows are unmasked, so the model can sample <tool_call> while thinking. A pure JSON-schema grammar then rejects it, and the scheduler terminates the request. Before #59627 the token was skipped instead, which gave a 200 with empty content. This PR bans such tokens in reasoning rows when the grammar can't start with them. The model ends reasoning with </think> instead, so nothing the grammar rejects is ever fed to it. Grammars that do accept <tool_call> (required, named, or auto tool tags) are unaffected, so the duplicated <tool_call> fix from #59627 is kept.

Setup. GLM-5.3-Flash-NVFP4, TP2, glm47 tool and reasoning parsers, xgrammar, 8 seeds per row. I compared main (a2fb482) with this PR (6d144d6). The failing shape is a conversation whose tool result quotes GLM's own tags (<tool_call>, <arg_key>, <arg_value>), followed by a request with response_format: json_schema and a pure JSON grammar, meaning no tools or tool_choice: "none".

Without spec decode

Request main this PR
tagged tool result, json_schema, no tools 7/8 500s, 0/8 valid JSON 0 500s, 4/8 valid JSON
same, tool_choice: "none" 5/8 500s, 3/8 valid JSON 0 500s, 7/8 valid JSON
logit_bias forcing <tool_call> (and blocking </think>) 8/8 500s 0 500s, empty content
untagged tool result, json_schema 8/8 valid JSON 8/8 valid JSON
tagged, tool_choice: "required" 0 duplicated <tool_call> 0 duplicated <tool_call>

With MTP spec decode (num_speculative_tokens=3, Model Runner V2, async scheduling)

Request main this PR
tagged tool result, json_schema, no tools 6/8 500s, 2/8 valid JSON 0 500s, 6/8 valid JSON
same, tool_choice: "none" 2/8 500s, 6/8 valid JSON 0 500s, 8/8 valid JSON
untagged tool result, json_schema 8/8 valid JSON 8/8 valid JSON
tagged, tool_choice: "required" 0 duplicated <tool_call> 0 duplicated <tool_call>

The server log on main has one Failed to advance FSM per 500. The PR's logs have none and no tracebacks. I also ran the CPU unit path (StructuredOutputManager with the GLM tokenizer, xgrammar and guidance): <tool_call> is masked in every reasoning row for json_schema and stays allowed for a required tool tag.

Notes:

  • The remaining invalid JSON responses are not grammar failures. The raw token stream is valid JSON, but the model quotes <tool_call> inside a JSON string, and the frontend cuts content at that point. That is a separate, pre-existing issue that this PR doesn't touch.
  • The logit_bias row can't run under spec decode (400). I didn't test the V1 model runner.
  • With MTP I needed --max-num-seqs 256 on both builds, because the default exceeds the Mamba cache blocks. This is unrelated to the PR.

This verification was done with AI assistance (Claude).

@simon-veitner-redhat

Copy link
Copy Markdown
Contributor

cc @yzong-rh

@vMaroon

vMaroon commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@simon-veitner-redhat your H200 run covers what the CPU validation here could not reach: Model Runner V2, async scheduling and MTP. The CPU end to end ran the V1 model runner, so between the two both runners are covered. I moved your results into the description.

Agree the cut content is separate. The parser matches <tool_call> as text inside content, so a tag the model quotes in a JSON string ends the content there. Glm47MoeParser.parse on </think>{"answer": "the log shows <tool_call>get_weather<arg_key>city</arg_key> as text"} returns content {"answer": "the log shows, and with no tools on the request it also returns a get_weather call. Wonder if we could skip the tool-call terminals when the request cannot call tools (no tools, or tool_choice: "none"). I'll track that separately.

@vMaroon

vMaroon commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

/ci run

@github-actions

Copy link
Copy Markdown

❌ @vMaroon, A reviewer with write access must run /ci run, approve the PR, or add the ready label first.

@robertgshaw2-redhat robertgshaw2-redhat added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 14, 2026
@robertgshaw2-redhat

Copy link
Copy Markdown
Collaborator

/ci run

@github-actions

Copy link
Copy Markdown

✅ @vMaroon, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Run commands test your branch with every commit currently on its upstream target branch. If it is behind, the bot merges the target branch into it first (pull before pushing again). Append --allow-stale to a run command to test an outdated branch as is, at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #94748 for commit ec69c8a53c71.

@chaunceyjiang chaunceyjiang left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think it would be better to paste some actual E2E test results into the PR description.

@simon-veitner-redhat

simon-veitner-redhat commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

@chaunceyjiang see below for E2E test.

Reproducing the GLM-5.3 structured-output 500 (PR #61574)

What fails

GLM-5.3 can end reasoning implicitly by emitting <tool_call> (token 154843) instead of </think>.
While the request is still reasoning, its grammar bitmask is not applied, so the sampler can pick 154843 freely.
Since #59627, that token is then fed to the request's grammar.
When the grammar is a pure JSON schema (response_format with no tools, or tool_choice: "none"), it cannot accept <tool_call>.
xgrammar rejects the token, the engine logs Failed to advance FSM, and the request returns HTTP 500.
Before #59627 the same requests returned a 200 with empty content.

PR #61574 masks implicit reasoning-end tokens in reasoning rows when the grammar cannot start with them.
The model then ends reasoning with </think> and produces schema-valid JSON.

With tools and tool_choice: "auto" the grammar is a structural tag that accepts <tool_call>, so that shape does not 500.

Setup

Two builds of the same tree: main at a2fb4827 and the PR head 6d144d64, which is based on that commit.

git clone https://github.com/vllm-project/vllm && cd vllm
git checkout a2fb4827                                  # "main"
# PR build: git fetch origin pull/61574/head && git checkout FETCH_HEAD
VLLM_USE_PRECOMPILED=1 pip install -e .                # Python-only change, precompiled kernels are fine

Serve on two GPUs. I used H200s.
The non-spec server below logs backend='auto' and selects xgrammar.

CUDA_VISIBLE_DEVICES=0,1 vllm serve RedHatAI/GLM-5.3-Flash-NVFP4 --served-model-name glm \
  --tensor-parallel-size 2 --max-model-len 32768 --port 8000 \
  --enable-auto-tool-choice --tool-call-parser glm47 --reasoning-parser glm47

MTP variant with 3 speculative tokens.
Without --max-num-seqs 256 startup fails for me with max_num_seqs (1024) exceeds available Mamba cache blocks (1003).

CUDA_VISIBLE_DEVICES=0,1 vllm serve RedHatAI/GLM-5.3-Flash-NVFP4 --served-model-name glm \
  --tensor-parallel-size 2 --max-model-len 32768 --port 8000 --max-num-seqs 256 \
  --enable-auto-tool-choice --tool-call-parser glm47 --reasoning-parser glm47 \
  --structured-outputs-config '{"backend":"xgrammar"}' \
  --speculative-config '{"method":"mtp","num_speculative_tokens":3}'

The failing request

The conversation is a Read tool call whose result quotes GLM's own tags (<tool_call>, <arg_key>, <arg_value>).
The final turn asks for a JSON summary through response_format: json_schema.
Seeing the tags in context makes the model emit <tool_call> mid-reasoning on most seeds.

repro_glm_so_500.py below sends each row with seeds 0..N-1 and prints HTTP codes and JSON validity.
It uses only the standard library.

repro_glm_so_500.py
# Reproduces the GLM-5.3 "Failed to advance FSM ... for tokens 154843" HTTP 500
# (vllm-project/vllm#61574). Stdlib only. usage: python repro_glm_so_500.py [port] [n]
import json
import sys
import urllib.error
import urllib.request

PORT = sys.argv[1] if len(sys.argv) > 1 else "8000"
N = int(sys.argv[2]) if len(sys.argv) > 2 else 8
THINK_END, TOOL_CALL = 154842, 154843  # </think>, <tool_call>

# The tool result quotes GLM's own tool-call tags; that is what makes the model
# emit <tool_call> while still reasoning.
TAGGED = (
    "# Parser notes\n\nGLM-5.3 wraps every function call in <tool_call>...</tool_call>"
    " tags; each argument is <arg_key>k</arg_key><arg_value>v</arg_value>. Example:"
    " <tool_call>Read<arg_key>file_path</arg_key><arg_value>a.txt</arg_value>"
    "</tool_call>\n"
)
PLAIN = (
    "# Parser notes\n\nGLM-5.3 wraps every function call in tool-call tags; each "
    "argument is a key tag followed by a value tag. Example: a Read call with a "
    "file_path argument.\n"
)
TOOLS = [
    {"type": "function", "function": {"name": "Read", "description": "Read a file.",
     "parameters": {"type": "object", "required": ["file_path"],
                    "properties": {"file_path": {"type": "string"}}}}},
    {"type": "function", "function": {"name": "Write", "description": "Write a file.",
     "parameters": {"type": "object", "required": ["file_path", "content"],
                    "properties": {"file_path": {"type": "string"},
                                   "content": {"type": "string"}}}}},
]
SCHEMA = {
    "type": "object",
    "properties": {"summary": {"type": "string"}, "mentions_tags": {"type": "boolean"}},
    "required": ["summary", "mentions_tags"],
    "additionalProperties": False,
}
RF = {"type": "json_schema", "json_schema": {"name": "summary", "schema": SCHEMA}}


def messages(tool_result):
    return [
        {"role": "user", "content": "Read docs/parser.md, then give me a one-sentence "
         "summary of it and whether it mentions tags."},
        {"role": "assistant", "content": "", "tool_calls": [{"id": "call_1",
         "type": "function", "function": {"name": "Read",
                                          "arguments": "{\"file_path\": \"docs/parser.md\"}"}}]},
        {"role": "tool", "tool_call_id": "call_1", "content": tool_result},
    ]


ROWS = {
    "fail2: tagged + json_schema, no tools field": dict(
        messages=messages(TAGGED), response_format=RF),
    "fail3: tagged + json_schema, tool_choice none": dict(
        messages=messages(TAGGED), tools=TOOLS, response_format=RF, tool_choice="none"),
    "ctl: tagged + json_schema, tool_choice required": dict(
        messages=messages(TAGGED), tools=TOOLS, response_format=RF,
        tool_choice="required"),
    "ctl2: untagged + json_schema, no tools field": dict(
        messages=messages(PLAIN), response_format=RF),
    # Deterministic: forces <tool_call> during reasoning. Spec decode rejects
    # logit_bias with a 400, so this row only applies without MTP.
    "det: logit_bias <tool_call>, json_schema": dict(
        messages=[{"role": "user", "content": "What is the capital of France? Reply "
                   "with a summary and mentions_tags=false."}],
        response_format=RF, temperature=0,
        logit_bias={str(THINK_END): -100, str(TOOL_CALL): 10}),
}


def post(body):
    req = urllib.request.Request(
        f"http://localhost:{PORT}/v1/chat/completions", data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=600) as r:
            return r.status, json.loads(r.read())
    except urllib.error.HTTPError as e:
        return e.code, None


def json_valid(resp):
    try:
        obj = json.loads(resp["choices"][0]["message"].get("content") or "")
        return isinstance(obj, dict) and set(obj) == {"summary", "mentions_tags"}
    except (ValueError, KeyError, TypeError):
        return False


for name, extra in ROWS.items():
    codes, valid = {}, 0
    for seed in range(N):
        code, resp = post({"model": "glm", "max_tokens": 4000, "seed": seed, **extra})
        codes[code] = codes.get(code, 0) + 1
        valid += code == 200 and json_valid(resp)
    print(f"{name:50} codes={codes} 500s={codes.get(500, 0)}/{N} json_valid={valid}/{N}",
          flush=True)
python repro_glm_so_500.py 8000 8      # port, seeds per row

The rows:

Row Request shape
fail2 tagged tool result, response_format, no tools field
fail3 tagged tool result, response_format, tools, tool_choice: "none"
ctl tagged tool result, response_format, tools, tool_choice: "required"
ctl2 same as fail2 but the tool result describes the tags in words
det short prompt, temperature: 0, logit_bias {154842: -100, 154843: +10}; forces the bug on every seed

Expected results

Measured with N=8 on 2xH200, TP2, 2026-10-14. Cells are HTTP 500 counts out of 8, with valid JSON counts in parentheses.

Row main PR main + MTP PR + MTP
fail2 7/8 (0) 0/8 (4) 6/8 (2) 0/8 (6)
fail3 5/8 (3) 0/8 (7) 2/8 (6) 0/8 (8)
ctl 0/8 (0) 0/8 (0) 0/8 (0) 0/8 (0)
ctl2 0/8 (8) 0/8 (8) 0/8 (8) 0/8 (8)
det 8/8 (0) 0/8 (0) 400 400

The det row returns 400 under MTP because spec decode rejects logit_bias.
Without MTP, the PR returns 200 with finish_reason: "stop" and empty content (the bias blocks </think>). That row only checks that the 500 is gone.
The ctl row is never valid JSON because tool_choice: "required" produces a tool call instead of content.
Counts on the sampled rows (fail2, fail3) vary between runs; the 500s on main do not go away.

Each 500 on main has one matching engine log line, and the PR logs none:

grep "Failed to advance FSM" server.log
# ERROR ... [backend_xgrammar.py:269] Failed to advance FSM for request chatcmpl-... for tokens 154843. Please file an issue.

The client sees {"error":{"message":"Internal server error","type":"InternalServerError","code":500}}.

Without a GPU

The PR's unit tests cover the masking logic on CPU:

pytest tests/v1/structured_output/test_structured_output_manager.py \
  -k "implicit_end or grammar_invalid_implicit_end"

This reproduction and verification were done with AI assistance (Claude).

@vMaroon

vMaroon commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@chaunceyjiang Added them to the description under Validation:

  • End to end, CPU server: a real vllm serve with --reasoning-parser glm45 --tool-call-parser glm47, main vs this branch, 3 runs per request. JSON-schema requests that sample <tool_call> while reasoning return 500 on main and 200 on this branch; every request that returns 200 on main returns the same response here. The engine log on main has one grammar rejected tokens [154843] per 500 (12 of 12), none on this branch. There is a collapsed block with the raw request, the main 500 and engine log line, and the branch response.
  • Speculative decoding, end to end: ngram with 3 tokens, 500 on main, 200 here with 207 of 225 drafts accepted.
  • GPU, end to end: @simon-veitner-redhat's H200 runs on GLM-5.3-Flash-NVFP4 (TP2, 8 seeds per row), now pasted as tables, with and without MTP, Model Runner V2 and async scheduling. Main returns 500 in 2 to 8 of 8 runs per JSON-schema row; this branch returns none, and tool_choice: "required" is unchanged.

@yzong-rh

Copy link
Copy Markdown
Contributor

Thanks Maroon and Simon!

This seems to fix the remaining issue from #60722. More specifically, this refines the approach where we read "a model's implicit reasoning end marker and disallow them during sampling" if said reasoning end marker isn't grammar-valid.

The changes look reasonable to me. Only concern is that we are now constraining reasoning tokens despite enable_in_reasoning=False. Maybe we should make this opt-in via a --enable-strict-thinking flag like SGLang?

Since #59627, an implicit reasoning end (<tool_call>, token 154843 for GLM) is fed to the grammar. The first version of #59627 only did that when validate_tokens accepted the token, but that check was removed during review.

Would that have resolved this issue? IIUC, when we have a <tool_call>, we can either leak it (leading to #59608) or fail the request (as we do on main). Only way to resolve this is to prevent <tool_call> from being sampled in the first place by constraining reasoning, which is what this PR implements.

@vMaroon

vMaroon commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

/ci retry

@github-actions

Copy link
Copy Markdown

✅ Queued 1 failed job(s) for retry in Buildkite CI #94748.

@vMaroon

vMaroon commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@simon-veitner-redhat Follow-up on the content cut you noted: it is tracked in #55080, with the fix in #55089, and it is separate from this PR. Checked on CPU against main 13dbf96093 through the parser --tool-call-parser glm47 --reasoning-parser glm45 builds for chat completions, non-streaming. When JSON content contains the <tool_call> special token, the content comes back cut at that token for a request without tools, and with that span removed under tool_choice: "none". With #55089's parser change applied, both return the full text. Spelled as plain text, the tag already comes back unchanged.

@vMaroon

vMaroon commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@yzong-rh On the flag: I think the mask has to be the default, so a flag would only fit as an opt-out. Without the mask, a request whose grammar rejects the implicit end can still sample it and end in a 500, so an opt-in flag keeps that failure as the default. The constraint stays narrow: it removes only the implicit ends the request's own grammar rejects as its first token, </think> and every other token stay allowed, and requests whose grammar accepts the token see no change. So with enable_in_reasoning=False the grammar still does not shape the reasoning text; it only blocks the one token that would make the response fail. If maintainers want an escape hatch, wonder if we could add it here as an opt-out.

On the first version of #59627: agreed. validate_tokens there would have skipped the token instead of feeding it, which brings back the result from before #59627 (a 200 with empty content, per Simon), so the response is still unusable. Stopping the sample is the only way to get a valid response.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ready ONLY add when PR is ready to merge/full CI is needed speculative-decoding structured-output tool-calling

Projects

Status: No status
Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants