[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>
Yixin Dong committed
Sep 15, 2026 at 04:36 UTC
2c2cbd84fdf199901f3f4e6b0e708ee0664aadd6
2 files changed
+90
-4
tests/tool_parsers/test_structural_tag_registry.py
+77
-3
@@ -5,7 +5,7 @@ from types import SimpleNamespace
5
from unittest.mock import MagicMock
6
7
import pytest
8
-from xgrammar import Grammar, StructuralTag
8
+from xgrammar import Grammar, StructuralTag, builtin_structural_tag
9
from xgrammar.testing import _is_grammar_accept_string
10
11
from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -81,10 +81,14 @@ def test_supported_structural_tag_models_include_vllm_builtins():
81
82
83
@pytest.mark.parametrize("choice", ["auto", "required", "get_weather"])
84
-def test_deepseek_v41_format_constraints_ignore_parameter_schema(
84
+def test_deepseek_v41_older_xgrammar_keeps_format_constraints(
85
choice,
86
sample_tools_strict,
87
+ monkeypatch,
88
):
89
+ monkeypatch.setattr(
90
+ builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None, raising=False
91
+ )
92
tool_choice = (
93
ChatCompletionNamedToolChoiceParam(
94
function=ChatCompletionNamedFunction(name=choice)
@@ -128,7 +132,11 @@ def test_deepseek_v41_named_choice_emits_one_call(sample_tools):
132
reasoning=False,
133
)
134
grammar = Grammar.from_structural_tag(tag)
131
- call = '<|DSML| invoke name="get_weather">\n</|DSML| invoke>\n'
135
+ call = (
136
+ '<|DSML| invoke name="get_weather">\n'
137
+ '<|DSML| parameter name="city" string="true">Paris</|DSML| parameter>\n'
138
+ "</|DSML| invoke>\n"
139
+ )
140
assert _is_grammar_accept_string(
141
grammar, "\n\n<|DSML| calls>\n" + call + "</|DSML| calls>"
142
)
@@ -141,6 +149,72 @@ def test_deepseek_v41_named_choice_emits_one_call(sample_tools):
149
)
150
151
152
+@pytest.mark.skipif(
153
+ not hasattr(builtin_structural_tag, "get_deepseek_v4_1_structural_tag"),
154
+ reason="Requires XGrammar's DeepSeek V4.1 builtin",
155
+)
156
+@pytest.mark.parametrize("choice", ["auto", "required", "get_weather"])
157
+@pytest.mark.parametrize("reasoning", [False, True])
158
+def test_deepseek_v41_constrains_parameters_after_reasoning(
159
+ sample_tools_strict, choice, reasoning
160
+):
161
+ """Strict calls must enforce the schema without requiring another think close."""
162
+ tool_choice = (
163
+ ChatCompletionNamedToolChoiceParam(
164
+ function=ChatCompletionNamedFunction(name=choice)
165
+ )
166
+ if choice == "get_weather"
167
+ else choice
168
+ )
169
+ sample_tools_strict[0].function.parameters["additionalProperties"] = False
170
+ tag = get_model_structural_tag(
171
+ "deepseek_v41", sample_tools_strict, tool_choice, reasoning=reasoning
172
+ )
173
+ grammar = Grammar.from_structural_tag(tag)
174
+ begin = '\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n'
175
+ end = "</|DSML| invoke>\n</|DSML| calls>"
176
+ parameter = (
177
+ '<|DSML| parameter name="city" string="true">Paris</|DSML| parameter>\n'
178
+ )
179
+ assert _is_grammar_accept_string(grammar, begin + parameter + end)
180
+ for invalid in (
181
+ "", # missing required city
182
+ parameter * 2,
183
+ parameter.replace('name="city"', 'name="unknown"'),
184
+ parameter.replace('string="true">Paris', 'string="false">42'),
185
+ parameter.replace("| parameter", "|parameter"),
186
+ '{"city":"Paris"}', # the encoder emits DSML parameters, not a JSON body
187
+ ):
188
+ assert not _is_grammar_accept_string(grammar, begin + invalid + end)
189
+ assert not _is_grammar_accept_string(
190
+ grammar, "reason</think>" + begin + parameter + end
191
+ )
192
+ assert _is_grammar_accept_string(grammar, "Hello") == (choice == "auto")
193
+
194
+
195
+@pytest.mark.skipif(
196
+ not hasattr(builtin_structural_tag, "get_deepseek_v4_1_structural_tag"),
197
+ reason="Requires XGrammar's DeepSeek V4.1 builtin",
198
+)
199
+def test_deepseek_v41_non_strict_parallel_calls_keep_typed_dsml(sample_tools):
200
+ sample_tools[0].function.strict = False
201
+ tag = get_model_structural_tag(
202
+ "deepseek_v41", sample_tools, "required", reasoning=False
203
+ )
204
+ grammar = Grammar.from_structural_tag(tag)
205
+ call = (
206
+ '<|DSML| invoke name="get_weather">\n'
207
+ '<|DSML| parameter name="extra" string="false">'
208
+ '{"nested":[true,null,1.5]}</|DSML| parameter>\n'
209
+ "</|DSML| invoke>\n"
210
+ )
211
+ output = "\n\n<|DSML| calls>\n" + call * 2 + "</|DSML| calls>"
212
+ assert _is_grammar_accept_string(grammar, output)
213
+ assert not _is_grammar_accept_string(
214
+ grammar, output.replace('{"nested":[true,null,1.5]}', "invalid")
215
+ )
216
+
217
+
218
@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS))
219
def test_get_model_structural_tag_supports_all_xgrammar_builtins(
220
model: str,
vllm/tool_parsers/structural_tag_registry.py
+13
-1
@@ -10,6 +10,7 @@ from openai.types.responses.tool import Tool as ResponsesTool
10
from openai.types.responses.tool_choice_allowed import ToolChoiceAllowed
11
from openai.types.responses.tool_choice_function import ToolChoiceFunction
12
from xgrammar import StructuralTag, normalize_tool_choice
13
+from xgrammar import builtin_structural_tag as xgrammar_builtin_structural_tag
14
from xgrammar import get_model_structural_tag as get_xgrammar_model_structural_tag
15
from xgrammar.openai_tool_call_schema import (
16
BuiltinToolParam,
@@ -242,9 +243,20 @@ def get_deepseek_v41_structural_tag(
243
token_suffix: str = "",
244
) -> StructuralTag:
245
# Serving enables this visible-text grammar after the reasoning boundary.
246
+ builder = getattr(
247
+ xgrammar_builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None
248
+ )
249
+ if builder is not None:
250
+ return builder(
251
+ tools=tools,
252
+ builtin_tools=builtin_tools,
253
+ tool_choice=tool_choice,
254
+ reasoning="disabled",
255
+ )
256
+
257
del builtin_tools, reasoning, token_suffix
258
247
- # TODO: lower parameter schemas into DSML constraints. This builder constrains
259
+ # Compatibility with xgrammar releases without the V4.1 builtin. This constrains
260
# tool names and DSML/value syntax only. Parameter names, presence, uniqueness
261
# and value schemas remain unconstrained, including for strict=true. The request
262
# layer still uses strict to decide whether auto tool choice activates a grammar.