[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)