[feature] [xgrammar] support `patternProperties`/`propertyNames`/`unevaluatedProperties` kw for object types (#42904)

Signed-off-by: cjackal <44624812+cjackal@users.noreply.github.com> Co-authored-by: Artem Perevedentsev <aperevedents@nvidia.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

cjackal committed Sep 15, 2026 at 13:58 UTC cd10ed6f9f6b37a8ace9cf380007e66fe12ec0c3
3 files changed +172 -16
tests/v1/structured_output/test_utils.py
+130 -8
@@ -2,6 +2,8 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import pytest
5 +from xgrammar import Grammar
6 +from xgrammar.testing import _is_grammar_accept_string
7
8 from vllm.v1.structured_output.backend_xgrammar import (
9 has_xgrammar_unsupported_json_features,
@@ -10,13 +12,19 @@ from vllm.v1.structured_output.backend_xgrammar import (
12 pytestmark = pytest.mark.cpu_test
13
14
15 +def grammar_accepts(schema: dict, text: str) -> bool:
16 + return _is_grammar_accept_string(Grammar.from_json_schema(schema), text)
17 +
18 +
19 @pytest.fixture
20 def unsupported_string_schemas():
21 return [
22 {"type": "string", "format": "non_existing_format"},
23 + # TODO(arpera):
24 # pattern/format is compiled but length bounds are silently dropped,
25 # so the combination must be rejected instead of producing quietly
26 # wrong output
27 + # https://github.com/mlc-ai/xgrammar/issues/749
28 {"type": "string", "pattern": "^a+$", "maxLength": 2},
29 {"type": "string", "pattern": "^a+$", "minLength": 3},
30 {"type": "string", "format": "email", "maxLength": 10},
@@ -48,15 +56,93 @@ def unsupported_array_schemas():
56
57
58 @pytest.fixture
51 -def unsupported_object_schemas():
59 +def unsupported_property_names_combinations():
60 + return [
61 + # TODO(arpera):
62 + # this case is not covered by xgrammar, so we should report this bug to xgrammar
63 + # xgrammar drops propertyNames whenever patternProperties is present,
64 + # so "grade_12" matches the pattern and escapes the name constraint
65 + {
66 + "type": "object",
67 + "patternProperties": {"^grade_[0-9]+$": {"type": "integer"}},
68 + "propertyNames": {"pattern": "^grade_[0-9]$"}, # does NOT match "grade_12"
69 + },
70 + # propertyNames makes xgrammar discard the sibling additionalProperties
71 + # value schema: https://github.com/mlc-ai/xgrammar/issues/826
72 + {
73 + "type": "object",
74 + "propertyNames": {"pattern": "^[a-z]+$"},
75 + "additionalProperties": {"type": "integer"},
76 + },
77 + # propertyNames is a string schema that conventionally omits "type", so
78 + # it escapes the string check while xgrammar drops its length bound all
79 + # the same: https://github.com/mlc-ai/xgrammar/issues/749
80 + {
81 + "type": "object",
82 + "propertyNames": {"pattern": "^a+$", "maxLength": 2},
83 + },
84 + # xgrammar emits named `properties` separately from `propertyNames` and
85 + # only applies `propertyNames` in its additional-properties branch, so a
86 + # key declared in `properties` escapes the name constraint regardless of
87 + # what `propertyNames` contains.
88 + {
89 + "type": "object",
90 + "properties": {"Bad": {"type": "integer"}},
91 + "required": ["Bad"],
92 + "propertyNames": {"pattern": "^[a-z]+$"},
93 + },
94 + {
95 + "type": "object",
96 + "properties": {"Bad": {"type": "integer"}},
97 + "propertyNames": {"enum": ["good"]},
98 + },
99 + # xgrammar's propertyNames-only branch falls back to an unconstrained
100 + # value type, so it drops unevaluatedProperties whether it restricts
101 + # values (a schema) or forbids them (false).
102 + {
103 + "type": "object",
104 + "propertyNames": {"pattern": "^[a-z]+$"},
105 + "unevaluatedProperties": {"type": "integer"},
106 + },
107 + {
108 + "type": "object",
109 + "propertyNames": {"pattern": "^[a-z]+$"},
110 + "unevaluatedProperties": False,
111 + },
112 + ]
113 +
114 +
115 +@pytest.fixture
116 +def unsupported_pattern_properties_combinations():
117 return [
53 - {"type": "object", "propertyNames": {"pattern": "^[a-z]+$"}},
54 - {"type": "object", "patternProperties": {"^S": {"type": "string"}}},
118 + # JSON Schema requires a property matched by both `properties` and
119 + # `patternProperties` to satisfy both (conjunction), but xgrammar
120 + # compiles them as alternative branches, so satisfying either one is
121 + # enough.
122 + {
123 + "type": "object",
124 + "properties": {"x": {"type": "string"}},
125 + "patternProperties": {"^x$": {"type": "integer"}},
126 + },
127 + # The same alternative-branches problem applies to overlapping
128 + # patternProperties patterns: a key matching both patterns only has to
129 + # satisfy one of them.
130 + {
131 + "type": "object",
132 + "patternProperties": {
133 + "^a[a-z]*$": {"type": "string"},
134 + "^[a-z]*z$": {"type": "integer"},
135 + },
136 + },
137 ]
138
139
140 @pytest.fixture
59 -def supported_schema():
141 +def supported_frankenstein_schema():
142 + # IMPORTANT(arpera):
143 + # Do NOT add more keywords here! This schema is overcrowded enough that a new
144 + # keyword can end up having no effect on the compiled grammar while the
145 + # test still passes. Give a new keyword its own fixture and named test.
146 return {
147 "type": "object",
148 "properties": {
@@ -88,6 +174,19 @@ def supported_schema():
174 }
175
176
177 +@pytest.fixture
178 +def property_names_schema():
179 + return {"type": "object", "propertyNames": {"pattern": "^[a-z_]+$"}}
180 +
181 +
182 +@pytest.fixture
183 +def pattern_properties_schema():
184 + return {
185 + "type": "object",
186 + "patternProperties": {"^grade_[0-9]+$": {"type": "integer"}},
187 + }
188 +
189 +
190 @pytest.mark.parametrize(
191 "schema_type",
192 [
@@ -95,7 +194,8 @@ def supported_schema():
194 "unsupported_integer_schemas",
195 "unsupported_number_schemas",
196 "unsupported_array_schemas",
98 - "unsupported_object_schemas",
197 + "unsupported_property_names_combinations",
198 + "unsupported_pattern_properties_combinations",
199 ],
200 )
201 def test_unsupported_json_features_by_type(schema_type, request):
@@ -106,7 +206,29 @@ def test_unsupported_json_features_by_type(schema_type, request):
206 )
207
208
109 -def test_supported_json_features(supported_schema):
110 - assert not has_xgrammar_unsupported_json_features(supported_schema), (
111 - "Schema should be supported"
209 +@pytest.mark.parametrize(
210 + "schema_type",
211 + [
212 + "supported_frankenstein_schema",
213 + "pattern_properties_schema",
214 + "property_names_schema",
215 + ],
216 +)
217 +def test_supported_json_features(schema_type, request):
218 + schema = request.getfixturevalue(schema_type)
219 + assert not has_xgrammar_unsupported_json_features(schema), (
220 + f"Schema should be supported: {schema}"
221 )
222 +
223 +
224 +def test_property_names_constrains_keys(property_names_schema):
225 + assert grammar_accepts(property_names_schema, '{"score": 5}')
226 + assert grammar_accepts(property_names_schema, '{"score": "seven"}')
227 + assert not grammar_accepts(property_names_schema, '{"Score": 5}')
228 + assert not grammar_accepts(property_names_schema, '{"score_1": 5}')
229 +
230 +
231 +def test_pattern_properties_constrains_keys_and_values(pattern_properties_schema):
232 + assert grammar_accepts(pattern_properties_schema, '{"grade_1": 5}')
233 + assert not grammar_accepts(pattern_properties_schema, '{"other": 5}')
234 + assert not grammar_accepts(pattern_properties_schema, '{"grade_1": "five"}')
tests/v1/structured_output/test_validation.py
+7 -2
@@ -137,7 +137,7 @@ def test_unsupported_grammar_is_a_client_error(backend, structured_outputs):
137 @pytest.mark.parametrize(
138 "schema, expected_backend",
139 [
140 - # multipleOf is unsupported by xgrammar, patternProperties also by guidance.
140 + # multipleOf is unsupported by xgrammar.
141 (
142 {
143 "type": "object",
@@ -145,8 +145,13 @@ def test_unsupported_grammar_is_a_client_error(backend, structured_outputs):
145 },
146 "guidance",
147 ),
148 + # patternProperties + properties is also unsupported by guidance.
149 (
149 - {"type": "object", "patternProperties": {"^a": {"type": "string"}}},
150 + {
151 + "type": "object",
152 + "properties": {"a": {"type": "string"}},
153 + "patternProperties": {"^a$": {"type": "string"}},
154 + },
155 "outlines",
156 ),
157 ],
vllm/v1/structured_output/backend_xgrammar.py
+35 -6
@@ -236,6 +236,12 @@ STRING_SUPPORTED_FORMATS = {
236 }
237
238
239 +def _has_pattern_and_length_bounds(schema: dict[str, Any]) -> bool:
240 + return ("pattern" in schema or "format" in schema) and (
241 + "minLength" in schema or "maxLength" in schema
242 + )
243 +
244 +
245 def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
246 """Check if JSON schema contains features unsupported by xgrammar."""
247
@@ -269,16 +275,39 @@ def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
275 # the compiled EBNF: pattern/format grammars come out byte-identical
276 # with and without the length keywords, while maxLength alone lowers
277 # to {0, N} correctly.
278 + if obj.get("type") == "string" and _has_pattern_and_length_bounds(obj):
279 + return True
280 +
281 + # propertyNames validates names, so it is a string schema even when it
282 + # omits "type", which is the form that escapes the check above.
283 if (
273 - obj.get("type") == "string"
274 - and ("pattern" in obj or "format" in obj)
275 - and ("minLength" in obj or "maxLength" in obj)
284 + obj.get("type") == "object"
285 + and isinstance(obj.get("propertyNames"), dict)
286 + and _has_pattern_and_length_bounds(obj["propertyNames"])
287 ):
288 return True
289
279 - # Unsupported keywords for objects
280 - if obj.get("type") == "object" and any(
281 - key in obj for key in ("patternProperties", "propertyNames")
290 + # FIXME: propertyNames conflicts with properties/patternProperties/
291 + # additionalProperties/unevaluatedProperties under xgrammar.
292 + # https://github.com/mlc-ai/xgrammar/issues/826
293 + if (
294 + obj.get("type") == "object"
295 + and "propertyNames" in obj
296 + and (
297 + "properties" in obj
298 + or "patternProperties" in obj
299 + or isinstance(obj.get("additionalProperties"), dict)
300 + or obj.get("unevaluatedProperties", True) is not True
301 + )
302 + ):
303 + return True
304 +
305 + # FIXME: multiple patternProperties, or patternProperties alongside
306 + # properties, conflict under xgrammar.
307 + if (
308 + obj.get("type") == "object"
309 + and isinstance(obj.get("patternProperties"), dict)
310 + and ("properties" in obj or len(obj["patternProperties"]) > 1)
311 ):
312 return True
313