[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