[Bugfix] Escape control characters in xgrammar choice grammar (#48115)

Signed-off-by: john <6712432+CaiJohn@users.noreply.github.com> Co-authored-by: john <6712432+CaiJohn@users.noreply.github.com>

CaiJohn committed Sep 17, 2026 at 20:57 UTC 75c71390d5b399f5397a9166920fc45902f99f14
2 files changed +58 -3
tests/v1/structured_output/test_utils.py
+46
@@ -5,9 +5,12 @@ import pytest
5 from xgrammar import Grammar
6 from xgrammar.testing import _is_grammar_accept_string
7
8 +from vllm.sampling_params import SamplingParams, StructuredOutputsParams
9 from vllm.v1.structured_output.backend_xgrammar import (
10 has_xgrammar_unsupported_json_features,
11 + validate_xgrammar_grammar,
12 )
13 +from vllm.v1.structured_output.utils import choice_as_grammar
14
15 pytestmark = pytest.mark.cpu_test
16
@@ -232,3 +235,46 @@ def test_pattern_properties_constrains_keys_and_values(pattern_properties_schema
235 assert grammar_accepts(pattern_properties_schema, '{"grade_1": 5}')
236 assert not grammar_accepts(pattern_properties_schema, '{"other": 5}')
237 assert not grammar_accepts(pattern_properties_schema, '{"grade_1": "five"}')
238 +
239 +
240 +class TestIsGrammarAcceptString:
241 + class TestPR48115Regressions:
242 + @pytest.mark.parametrize("codepoint", [*range(0x20), 0x7F])
243 + def test_choice_as_grammar_preserves_control_characters(self, codepoint):
244 + choice = f"a{chr(codepoint)}f"
245 + grammar = Grammar.from_ebnf(choice_as_grammar([choice]))
246 +
247 + assert _is_grammar_accept_string(grammar, choice)
248 + assert not _is_grammar_accept_string(grammar, "a")
249 + assert not _is_grammar_accept_string(grammar, "af")
250 + assert not _is_grammar_accept_string(grammar, f"a\\u{codepoint:04x}f")
251 +
252 + @pytest.mark.parametrize(
253 + ("choice", "other"),
254 + [
255 + (r"a\nf", "a\nf"),
256 + ('a quote " and a backslash \\', 'a quote " and a backslash '),
257 + ("café", "cafe"),
258 + ("日本語", "日本"),
259 + ("😀", "😁"),
260 + ],
261 + )
262 + def test_choice_as_grammar_preserves_literal_choices(self, choice, other):
263 + grammar = Grammar.from_ebnf(choice_as_grammar(["yes", choice]))
264 +
265 + assert _is_grammar_accept_string(grammar, "yes")
266 + assert _is_grammar_accept_string(grammar, choice)
267 + assert not _is_grammar_accept_string(grammar, other)
268 + assert not _is_grammar_accept_string(grammar, choice + "extra")
269 +
270 + def test_validate_xgrammar_preserves_multiline_choice(self):
271 + structured_outputs = StructuredOutputsParams(choice=["yes", "no\nplease"])
272 + validate_xgrammar_grammar(
273 + SamplingParams(structured_outputs=structured_outputs)
274 + )
275 +
276 + assert structured_outputs.choice is None
277 + grammar = Grammar.from_ebnf(structured_outputs.grammar)
278 + assert _is_grammar_accept_string(grammar, "yes")
279 + assert _is_grammar_accept_string(grammar, "no\nplease")
280 + assert not _is_grammar_accept_string(grammar, r"no\nplease")
vllm/v1/structured_output/utils.py
+12 -3
@@ -567,9 +567,18 @@ def convert_lark_to_ebnf(grammar_str: str) -> str:
567
568 def choice_as_grammar(choice: list[str]) -> str:
569 def escape_ebnf_string(s: str) -> str:
570 - """Escape special characters in a EBNF string."""
571 - # Escape double quotes and backslashes
572 - return re.sub(r'(["\\])', r"\\\1", s)
570 + """Escape EBNF literals, including raw LF, CR, and NUL terminators."""
571 + escapes = {"\\": r"\\", '"': r"\"", "\n": r"\n", "\r": r"\r", "\t": r"\t"}
572 +
573 + def escape_char(ch: str) -> str:
574 + if ch in escapes:
575 + return escapes[ch]
576 + # Escape remaining C0 controls (U+0000-U+001F) and DEL (U+007F).
577 + if ord(ch) < 0x20 or ord(ch) == 0x7F:
578 + return f"\\u{ord(ch):04x}"
579 + return ch
580 +
581 + return "".join(escape_char(ch) for ch in s)
582
583 escaped_choices = (escape_ebnf_string(c) for c in choice)
584 grammar = "root ::= " + " | ".join(f'"{c}"' for c in escaped_choices)