[Bugfix] Fix xgrammar feature gate bypass when JSON Schema type is a list (#48416)
Signed-off-by: john <6712432+CaiJohn@users.noreply.github.com> Co-authored-by: john <6712432+CaiJohn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
CaiJohn committed
Sep 21, 2026 at 14:59 UTC
3c84ad13ed3f9fcd816bf1d15acb83c28cfa02f9
3 files changed
+82
-7
tests/v1/structured_output/test_utils.py
+46
@@ -224,6 +224,52 @@ def test_supported_json_features(schema_type, request):
224
)
225
226
227
+class TestHasXGrammarUnsupportedJsonFeatures:
228
+ class TestPR48416Regressions:
229
+ @pytest.mark.parametrize(
230
+ "schema",
231
+ [
232
+ {"type": ["number"], "multipleOf": 3},
233
+ {
234
+ "type": ["string", "null"],
235
+ "pattern": "^a+$",
236
+ "maxLength": 2,
237
+ },
238
+ {
239
+ "type": ["array", "null"],
240
+ "items": {"type": "integer"},
241
+ "uniqueItems": True,
242
+ },
243
+ {
244
+ "type": ["object", "null"],
245
+ "properties": {"Bad": {"type": "integer"}},
246
+ "propertyNames": {"pattern": "^[a-z]+$"},
247
+ },
248
+ ],
249
+ )
250
+ def test_list_type_does_not_bypass_unsupported_feature_check(self, schema):
251
+ assert has_xgrammar_unsupported_json_features(schema)
252
+
253
+ @pytest.mark.parametrize(
254
+ "schema",
255
+ [
256
+ {"type": ["string", "null"]},
257
+ {"type": ["integer", "string"]},
258
+ {"type": ["array", "null"], "items": {"type": "string"}},
259
+ {
260
+ "type": ["object", "null"],
261
+ "propertyNames": {"pattern": "^[a-z_]+$"},
262
+ },
263
+ {
264
+ "type": ["object", "null"],
265
+ "patternProperties": {"^S": {"type": "string"}},
266
+ },
267
+ ],
268
+ )
269
+ def test_supported_list_type_json_features(self, schema):
270
+ assert not has_xgrammar_unsupported_json_features(schema)
271
+
272
+
273
def test_property_names_constrains_keys(property_names_schema):
274
assert grammar_accepts(property_names_schema, '{"score": 5}')
275
assert grammar_accepts(property_names_schema, '{"score": "seven"}')
tests/v1/structured_output/test_validation.py
+15
@@ -145,6 +145,21 @@ def test_unsupported_grammar_is_a_client_error(backend, structured_outputs):
145
},
146
"guidance",
147
),
148
+ (
149
+ {
150
+ "type": "object",
151
+ "properties": {"n": {"type": ["number", "null"], "multipleOf": 3}},
152
+ },
153
+ "guidance",
154
+ ),
155
+ (
156
+ {
157
+ "type": ["string", "null"],
158
+ "pattern": "^a+$",
159
+ "maxLength": 2,
160
+ },
161
+ "guidance",
162
+ ),
163
# patternProperties + properties is also unsupported by guidance.
164
(
165
{
vllm/v1/structured_output/backend_xgrammar.py
+21
-7
@@ -242,6 +242,18 @@ def _has_pattern_and_length_bounds(schema: dict[str, Any]) -> bool:
242
)
243
244
245
+# FIXME(arpera): The approach used here needs to be redesigned because of
246
+# existing bugs: https://github.com/vllm-project/vllm/issues/57550
247
+def _schema_types(schema: dict[str, Any]) -> set[str]:
248
+ """Normalize a scalar or list-valued JSON Schema type."""
249
+ schema_type = schema.get("type")
250
+ if isinstance(schema_type, str):
251
+ return {schema_type}
252
+ if isinstance(schema_type, list):
253
+ return {item for item in schema_type if isinstance(item, str)}
254
+ return set()
255
+
256
+
257
def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
258
"""Check if JSON schema contains features unsupported by xgrammar."""
259
@@ -249,12 +261,14 @@ def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
261
if not isinstance(obj, dict):
262
return False
263
264
+ schema_types = _schema_types(obj)
265
+
266
# Check for numeric ranges
253
- if obj.get("type") in ("integer", "number") and ("multipleOf" in obj):
267
+ if (schema_types & {"integer", "number"}) and ("multipleOf" in obj):
268
return True
269
270
# Check for array unsupported keywords
257
- if obj.get("type") == "array" and any(
271
+ if "array" in schema_types and any(
272
key in obj
273
for key in ("uniqueItems", "contains", "minContains", "maxContains")
274
):
@@ -262,7 +276,7 @@ def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
276
277
# Unsupported keywords for strings
278
if (
265
- obj.get("type") == "string"
279
+ "string" in schema_types
280
and "format" in obj
281
and obj["format"] not in STRING_SUPPORTED_FORMATS
282
):
@@ -275,13 +289,13 @@ def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
289
# the compiled EBNF: pattern/format grammars come out byte-identical
290
# with and without the length keywords, while maxLength alone lowers
291
# to {0, N} correctly.
278
- if obj.get("type") == "string" and _has_pattern_and_length_bounds(obj):
292
+ if "string" in schema_types and _has_pattern_and_length_bounds(obj):
293
return True
294
295
# propertyNames validates names, so it is a string schema even when it
296
# omits "type", which is the form that escapes the check above.
297
if (
284
- obj.get("type") == "object"
298
+ "object" in schema_types
299
and isinstance(obj.get("propertyNames"), dict)
300
and _has_pattern_and_length_bounds(obj["propertyNames"])
301
):
@@ -291,7 +305,7 @@ def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
305
# additionalProperties/unevaluatedProperties under xgrammar.
306
# https://github.com/mlc-ai/xgrammar/issues/826
307
if (
294
- obj.get("type") == "object"
308
+ "object" in schema_types
309
and "propertyNames" in obj
310
and (
311
"properties" in obj
@@ -305,7 +319,7 @@ def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
319
# FIXME: multiple patternProperties, or patternProperties alongside
320
# properties, conflict under xgrammar.
321
if (
308
- obj.get("type") == "object"
322
+ "object" in schema_types
323
and isinstance(obj.get("patternProperties"), dict)
324
and ("properties" in obj or len(obj["patternProperties"]) > 1)
325
):