[Frontend] Use XGrammar schema constraints for DeepSeek V4.1 (#56408)

Signed-off-by: Ubospica <ubospica@gmail.com>
Co-authored-by: Flora Feng <4florafeng@gmail.com>
This commit is contained in:
Yixin Dong
2026-09-15 04:36:16 -04:00
committed by GitHub
co-authored by Flora Feng
parent 676650397f
commit 2c2cbd84fd
2 changed files with 90 additions and 4 deletions
@@ -5,7 +5,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from xgrammar import Grammar, StructuralTag
from xgrammar import Grammar, StructuralTag, builtin_structural_tag
from xgrammar.testing import _is_grammar_accept_string
from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -81,10 +81,14 @@ def test_supported_structural_tag_models_include_vllm_builtins():
@pytest.mark.parametrize("choice", ["auto", "required", "get_weather"])
def test_deepseek_v41_format_constraints_ignore_parameter_schema(
def test_deepseek_v41_older_xgrammar_keeps_format_constraints(
choice,
sample_tools_strict,
monkeypatch,
):
monkeypatch.setattr(
builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None, raising=False
)
tool_choice = (
ChatCompletionNamedToolChoiceParam(
function=ChatCompletionNamedFunction(name=choice)
@@ -128,7 +132,11 @@ def test_deepseek_v41_named_choice_emits_one_call(sample_tools):
reasoning=False,
)
grammar = Grammar.from_structural_tag(tag)
call = '<|DSML| invoke name="get_weather">\n</|DSML| invoke>\n'
call = (
'<|DSML| invoke name="get_weather">\n'
'<|DSML| parameter name="city" string="true">Paris</|DSML| parameter>\n'
"</|DSML| invoke>\n"
)
assert _is_grammar_accept_string(
grammar, "\n\n<|DSML| calls>\n" + call + "</|DSML| calls>"
)
@@ -141,6 +149,72 @@ def test_deepseek_v41_named_choice_emits_one_call(sample_tools):
)
@pytest.mark.skipif(
not hasattr(builtin_structural_tag, "get_deepseek_v4_1_structural_tag"),
reason="Requires XGrammar's DeepSeek V4.1 builtin",
)
@pytest.mark.parametrize("choice", ["auto", "required", "get_weather"])
@pytest.mark.parametrize("reasoning", [False, True])
def test_deepseek_v41_constrains_parameters_after_reasoning(
sample_tools_strict, choice, reasoning
):
"""Strict calls must enforce the schema without requiring another think close."""
tool_choice = (
ChatCompletionNamedToolChoiceParam(
function=ChatCompletionNamedFunction(name=choice)
)
if choice == "get_weather"
else choice
)
sample_tools_strict[0].function.parameters["additionalProperties"] = False
tag = get_model_structural_tag(
"deepseek_v41", sample_tools_strict, tool_choice, reasoning=reasoning
)
grammar = Grammar.from_structural_tag(tag)
begin = '\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n'
end = "</|DSML| invoke>\n</|DSML| calls>"
parameter = (
'<|DSML| parameter name="city" string="true">Paris</|DSML| parameter>\n'
)
assert _is_grammar_accept_string(grammar, begin + parameter + end)
for invalid in (
"", # missing required city
parameter * 2,
parameter.replace('name="city"', 'name="unknown"'),
parameter.replace('string="true">Paris', 'string="false">42'),
parameter.replace("| parameter", "|parameter"),
'{"city":"Paris"}', # the encoder emits DSML parameters, not a JSON body
):
assert not _is_grammar_accept_string(grammar, begin + invalid + end)
assert not _is_grammar_accept_string(
grammar, "reason</think>" + begin + parameter + end
)
assert _is_grammar_accept_string(grammar, "Hello") == (choice == "auto")
@pytest.mark.skipif(
not hasattr(builtin_structural_tag, "get_deepseek_v4_1_structural_tag"),
reason="Requires XGrammar's DeepSeek V4.1 builtin",
)
def test_deepseek_v41_non_strict_parallel_calls_keep_typed_dsml(sample_tools):
sample_tools[0].function.strict = False
tag = get_model_structural_tag(
"deepseek_v41", sample_tools, "required", reasoning=False
)
grammar = Grammar.from_structural_tag(tag)
call = (
'<|DSML| invoke name="get_weather">\n'
'<|DSML| parameter name="extra" string="false">'
'{"nested":[true,null,1.5]}</|DSML| parameter>\n'
"</|DSML| invoke>\n"
)
output = "\n\n<|DSML| calls>\n" + call * 2 + "</|DSML| calls>"
assert _is_grammar_accept_string(grammar, output)
assert not _is_grammar_accept_string(
grammar, output.replace('{"nested":[true,null,1.5]}', "invalid")
)
@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS))
def test_get_model_structural_tag_supports_all_xgrammar_builtins(
model: str,
+13 -1
View File
@@ -10,6 +10,7 @@ from openai.types.responses.tool import Tool as ResponsesTool
from openai.types.responses.tool_choice_allowed import ToolChoiceAllowed
from openai.types.responses.tool_choice_function import ToolChoiceFunction
from xgrammar import StructuralTag, normalize_tool_choice
from xgrammar import builtin_structural_tag as xgrammar_builtin_structural_tag
from xgrammar import get_model_structural_tag as get_xgrammar_model_structural_tag
from xgrammar.openai_tool_call_schema import (
BuiltinToolParam,
@@ -242,9 +243,20 @@ def get_deepseek_v41_structural_tag(
token_suffix: str = "",
) -> StructuralTag:
# Serving enables this visible-text grammar after the reasoning boundary.
builder = getattr(
xgrammar_builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None
)
if builder is not None:
return builder(
tools=tools,
builtin_tools=builtin_tools,
tool_choice=tool_choice,
reasoning="disabled",
)
del builtin_tools, reasoning, token_suffix
# TODO: lower parameter schemas into DSML constraints. This builder constrains
# Compatibility with xgrammar releases without the V4.1 builtin. This constrains
# tool names and DSML/value syntax only. Parameter names, presence, uniqueness
# and value schemas remain unconstrained, including for strict=true. The request
# layer still uses strict to decide whether auto tool choice activates a grammar.