Repository navigation
🛡️ Fail closed on un-compilable grammar constraints; re-rail whitespace stalls - #481
sdougbrown wants to merge 2 commits into
Conversation
A json_schema, regex_pattern, or grammar_string that LLGuidance rejects was silently dropped: the filter was skipped, generation ran unconstrained, and the client received a 200 with output that ignores the requested constraint. The detailed error went to server stdout only. Fail closed instead. Filter construction now raises GrammarParseError, a new client-error type in common/errors.py alongside ContextLengthExceededError: - OAI chat/completions and Kobold non-streaming handlers map it to HTTPException(400) via the existing handle_request_error path; streaming handlers yield an error event carrying the compile failure. - A missing or broken llguidance backend re-raises the original exception so it keeps mapping to a server error (503), not a client error. - The per-request schema/grammar is logged once with its compile exception before the rejection, replacing the traceback.print_exc() stdout dump.
A grammar filter can only forbid tokens; it cannot compel the model to continue or stop. When the legal continuation set includes unlimited whitespace, a model that "feels done" (e.g. it skipped a trailing required property) can emit whitespace forever: every sampled token is legal, EOS and closing braces are masked, and the job burns its whole token budget on output that never satisfies the grammar. ExLlamaV3's loop detector does not catch this - its default 800-token window is tuned for long repeating sequences, not short legal attractors. WhitespaceStallGuardFilter wraps each grammar filter in Tabby: - Tracks accepted tokens. After stall_tokens (8) consecutive pure-whitespace tokens while the inner grammar is still incomplete, the next logit mask ANDs out whitespace, forcing the sampler onto a legal content token (or EOS). Each re-rail advances the grammar state, so repeated stalls terminate. - If excluding whitespace would empty the allowed set (the grammar truly requires whitespace there), the unmasked grammar mask is returned. - Delegates feed/rewind/rollback/reset/attach and mirrors is_active, trigger_token and eos_after_completed into the wrapped filter so job-level activation and deactivation keep working; pre-trigger history is cleared on activation. - Whitespace token ids come from one packed bitmask built lazily per handler from the tokenizer vocab (inner get_vocab, extended_id_to_piece fallback; SentencePiece "▁" counts as space; special tokens never count). If the bitmask cannot be built the guard disables itself and generation behaves as before. Grammar handlers wrap every constructed LLGuidanceFilter with a request-level threshold (stall_guard_tokens, default 8, 0/null disables, logged with the request settings); no exllamav3 changes are required.
|
I've merged the first commit (fail on unparsable constraint), but for the whitespace, LLGuidance has a Is this sufficient? |
|
probably! i'll check it out, thanks! |
|
@turboderp can finally confirm that technically yes the whitespace stall is solved with that config yes. the interesting thing going on here though is that now the problem moved. with whitespace forbidden a model that thinks it's done before the schema is satisfied keeps repeating characters inside a string instead haha. i think based on my read of this is it's because the schema is applied as a mask but never exposed to the model unless manually put into the prompt. putting the schema in the system prompt (iirc vllm and sglang do this) would probably fix it, unless it's expected that the schema should be manually included each time? i think this can be closed and i'd be happy to look into the schema system prompt injection if you want |
Note
I was very pleased to see the structured output support but encountered problems with it using gemma4. This addresses the issues I found.
Grammar-constrained requests (json_schema, regex_pattern, grammar_string) fail in two ways:
length, and the output never satisfies the grammar. ExLlamaV3 loop detection does not catch this case: its 800-token window targets longer repeating sequences.This branch fixes both. It carries two commits, and each commit is self-contained. I can split them into separate PRs if you prefer.
Commit 1: refuse to generate unconstrained on compile failure
LLGuidanceFilterconstruction failures now raiseGrammarParseError(new, incommon/errors.py) instead of logging and skipping the filter.except GrammarParseErrorclause in each consumer maps the error. Three non-streaming handlers return HTTP 400. These are OpenAI-compatible (OAI)chat/completions,completions, and Kobold. Streaming handlers yield an error event that carries the compile failure.llguidanceinstallation is missing or broken, the code re-raises the original exception. That failure still maps to a server error (503), not a client error.Commit 2: re-rail whitespace stalls
WhitespaceStallGuardFilterwraps each grammar filter. Afterstall_guard_tokens(default 8) consecutive pure-whitespace tokens, while the grammar is incomplete, the next logit mask excludes whitespace. The sampler then picks a legal content token, or an end-of-sequence (EOS) token. Each re-rail advances the grammar state, so repeated stalls terminate.feed,rewind,rollback,reset, andattachmethods. It also mirrorsis_active,trigger_token, andeos_after_completed. Job-level activation and deactivation keep working.get_vocab()and falls back toextended_id_to_piece. If the bitmask cannot be built, the guard disables itself.stall_guard_tokensis a sampling param (0 or null disables; default 8) and is logged with the request settings.Measured
Hardware: NVIDIA RTX 3090. Model: Gemma4-26B-A4B EXL3 3.1bpw. Identical prompts at temp 0 and at temp 1.0, top_p 0.95, top_k 40:
length, ~300 tokens, unparseablestop, ~160 tokens, valid JSONValidation
python -m pytest tests/: 182 passed (157 pre-existing, 25 new).GrammarParseErrorpropagation for schema, regex, and llama.cpp GBNF (grammar format) inputs.is_activemirroring, plus the disable and threshold plumbing.Limitations and follow-ups
stall_guard_tokens: 0per request to opt out.stall_guard_tokenshas no sampler-override preset entry yet. Add one if you want per-preset control.