Skip to content

xgrammar-generation-eos-stop

Source: fixes/xgrammar-generation-eos-stop/

Let a completed grammar end the turn with the model's real end-of-turn tokens, not only the tokenizer's EOS.

Affects structured outputs and forced tool calls (xgrammar backend) on models whose turn ends with a token other than tokenizer.eos_token_id
Releases v0.26.0 (v0.30.0 already has the upstream fix; the script reports "not needed")
Patches vllm/v1/structured_output/backend_xgrammar.py
Upstream fixed differently by vllm#49227 (merged; in the vLLM commit v0.30.0 tracks)
Test test_xgrammar_generation_eos_stop.py (stdlib only)

Problem

The xgrammar backend builds its TokenizerInfo with TokenizerInfo.from_huggingface(tokenizer, vocab_size=...), so the grammar's only stop token is tokenizer.eos_token_id. Many chat models end a turn with a different token that is listed in generation_config.json under eos_token_id. The engine already stops on those, but the grammar does not know them. After a complete tool call under tool_choice="required", xgrammar allows only another call or the tokenizer EOS, and masks the real end-of-turn token.

Symptom

With tool_choice="required" (or a named tool), the model keeps emitting tool calls until max_tokens, often dozens of them, and the response ends with finish_reason: "length". GLM-4.7 and the GLM-5 family show it reliably.

Who is affected

Users of the xgrammar backend (the default choice of auto) with models whose generation_config.json lists end tokens that differ from the tokenizer EOS.

The fix

Pass stop_token_ids = tokenizer EOS plus the generation config's eos_token_id values (inside the vocabulary) to xgrammar. A grammar can still stop only where it is complete; the fix only widens which end token is legal there to the set the engine already treats as end of turn.

vLLM #49227 fixes the same problem per request, by handing each request's stop tokens to the grammar matcher. The script detects that change and does nothing where it is present.

Verify

python3 fixes/xgrammar-generation-eos-stop/apply.py --check   # 0 = applied or not needed, 1 = not applied, 2 = source drifted
pytest fixes/xgrammar-generation-eos-stop

End to end: send a chat request with one tool and tool_choice: "required"; the response should contain one call (or a few) and finish with tool_calls, not run to max_tokens.