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