diff --git a/tests/v1/structured_output/test_utils.py b/tests/v1/structured_output/test_utils.py index c026ab0e4e..11b3e5c01b 100644 --- a/tests/v1/structured_output/test_utils.py +++ b/tests/v1/structured_output/test_utils.py @@ -14,6 +14,12 @@ pytestmark = pytest.mark.cpu_test def unsupported_string_schemas(): return [ {"type": "string", "format": "non_existing_format"}, + # pattern/format is compiled but length bounds are silently dropped, + # so the combination must be rejected instead of producing quietly + # wrong output + {"type": "string", "pattern": "^a+$", "maxLength": 2}, + {"type": "string", "pattern": "^a+$", "minLength": 3}, + {"type": "string", "format": "email", "maxLength": 10}, ] diff --git a/vllm/v1/structured_output/backend_xgrammar.py b/vllm/v1/structured_output/backend_xgrammar.py index 5b24a19780..3809ba2d91 100644 --- a/vllm/v1/structured_output/backend_xgrammar.py +++ b/vllm/v1/structured_output/backend_xgrammar.py @@ -262,6 +262,20 @@ def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool: ): return True + # A string mixing a generative constraint (pattern or format) with + # explicit length bounds. xgrammar compiles the pattern/format side + # and silently drops minLength/maxLength from the grammar, so output + # can violate the bound without any error surfacing. Verified against + # the compiled EBNF: pattern/format grammars come out byte-identical + # with and without the length keywords, while maxLength alone lowers + # to {0, N} correctly. + if ( + obj.get("type") == "string" + and ("pattern" in obj or "format" in obj) + and ("minLength" in obj or "maxLength" in obj) + ): + return True + # Unsupported keywords for objects if obj.get("type") == "object" and any( key in obj for key in ("patternProperties", "propertyNames")