feat: use XGrammar V4.1 DSML parameter constraints (#39026)

Co-authored-by: yuchuan <yuchuan.7streams@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
This commit is contained in:
Yixin Dong
2026-09-21 12:12:28 -07:00
committed by GitHub
co-authored by yuchuan Xinyuan Tong Xinyuan Tong
parent f0940fe3a6
commit ae7a516ba7
17 changed files with 380 additions and 97 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ dependencies = [
"uvicorn",
"uvloop",
"watchfiles",
"xgrammar==0.2.1",
"xgrammar==0.2.7",
"xxhash",
"zstandard",
]
+1 -1
View File
@@ -70,7 +70,7 @@ dependencies = [
"uvicorn",
"uvloop",
"xxhash",
"xgrammar==0.2.1",
"xgrammar==0.2.7",
"zstandard",
]
+1 -1
View File
@@ -67,7 +67,7 @@ dependencies = [
"uvicorn",
"uvloop",
"xxhash",
"xgrammar==0.2.1",
"xgrammar==0.2.7",
]
[project.optional-dependencies]
+1 -1
View File
@@ -73,7 +73,7 @@ runtime_common = [
"compressed-tensors",
"outlines==0.1.11",
"timm==1.0.16",
"xgrammar==0.2.1",
"xgrammar==0.2.7",
]
# srt_empty: device-agnostic install — pure Python packages only, no torch dependency chain.
+1 -1
View File
@@ -69,7 +69,7 @@ dependencies = [
"uvicorn",
"xxhash",
"uvloop",
# "xgrammar==0.2.1", xgrammar depends on CUDA PyTorch and Triton only
# "xgrammar==0.2.7", xgrammar depends on CUDA PyTorch and Triton only
]
[project.optional-dependencies]
@@ -205,6 +205,7 @@ POSITIONAL_FIELD_ORDER = (
"stat_loggers",
"constrained_json_whitespace_pattern",
"constrained_json_disable_any_whitespace",
"constrained_json_max_whitespace_cnt",
"attention_backend",
"decode_attention_backend",
"enable_lean_attention",
@@ -281,6 +281,10 @@ class Serving(msgspec.Struct):
bool,
"(xgrammar and llguidance backends only) Enforce compact representation in JSON constrained output.",
] = False
constrained_json_max_whitespace_cnt: A[
Optional[int],
"(xgrammar backend only) Max consecutive whitespace chars allowed in JSON constrained output. None means unbounded.",
] = None
# -------------------------------------------------------------------------
# Dynamic batch tokenizer
@@ -385,6 +385,7 @@ def create_grammar_backend(
vocab_size=vocab_size,
model_eos_token_ids=eos_list,
any_whitespace=not get_serving().constrained_json_disable_any_whitespace,
max_whitespace_cnt=get_serving().constrained_json_max_whitespace_cnt,
)
except TokenizerNotSupportedError as e:
if get_serving().enable_strict_thinking:
@@ -215,6 +215,7 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
vocab_size: int,
model_eos_token_ids: Optional[List[int]] = None,
any_whitespace: bool = True,
max_whitespace_cnt: Optional[int] = None,
):
super().__init__()
@@ -244,6 +245,7 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
self.vocab_size = vocab_size
self.override_stop_tokens = override_stop_tokens
self.any_whitespace = any_whitespace
self.max_whitespace_cnt = max_whitespace_cnt
@property
def is_support_token_filter(self):
@@ -348,7 +350,9 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
schema = json.loads(key_string)
validate_xgrammar_json_schema(schema)
ctx = self.grammar_compiler.compile_json_schema(
schema=key_string, any_whitespace=self.any_whitespace
schema=key_string,
any_whitespace=self.any_whitespace,
max_whitespace_cnt=self.max_whitespace_cnt,
)
except (
@@ -404,10 +404,8 @@ class BaseFormatDetector(ABC):
(the typical case when --reasoning-parser is configured) so
only one layer constrains the reasoning section.
parallel_tool_calls: Whether multiple tool calls may appear in one
assistant response. xgrammar's get_model_structural_tag does
not expose this knob, so this base implementation ignores it;
only detectors that build their own tags (e.g. Kimi K3)
honor it.
assistant response. Forwarded to XGrammar to constrain the
number of tool calls in the generated structural tag.
Returns:
StructuralTag if this detector supports model-native tags, otherwise None
@@ -427,6 +425,7 @@ class BaseFormatDetector(ABC):
tools=converted_tools,
tool_choice=converted_tool_choice,
reasoning=thinking_mode,
parallel_tool_calls=parallel_tool_calls,
)
def get_auto_tool_call_structural_tag(
@@ -75,6 +75,7 @@ class DeepSeekV32Detector(BaseFormatDetector):
tool_calls_block_name = "function_calls"
invoke_tag_name = "invoke"
parameter_tag_name = "parameter"
strip_string_param_value: bool = True
def __init__(self):
super().__init__()
@@ -170,7 +171,11 @@ class DeepSeekV32Detector(BaseFormatDetector):
# Convert value based on type
if param_type == "true": # string type
parameters[param_name] = param_value.strip()
parameters[param_name] = (
param_value.strip()
if self.strip_string_param_value
else param_value
)
else:
# Try to parse as JSON for other types
try:
@@ -195,7 +200,11 @@ class DeepSeekV32Detector(BaseFormatDetector):
if partial_match and (param_value := partial_match.group(3)):
param_name = partial_match.group(1)
if partial_match.group(2) == "true":
parameters[param_name] = param_value.strip()
parameters[param_name] = (
param_value.strip()
if self.strip_string_param_value
else param_value
)
else:
try:
parameters[param_name] = _partial_json_loads(
@@ -1,18 +1,5 @@
from typing import List, Literal, Optional, Union
from typing import Optional
from xgrammar.structural_tag import (
AnyTextFormat,
ConstStringFormat,
JSONSchemaFormat,
OrFormat,
SequenceFormat,
TagFormat,
TagsWithSeparatorFormat,
TriggeredTagsFormat,
)
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice
from sglang.srt.function_call.base_format_detector import StructuralTag
from sglang.srt.function_call.deepseekv32_detector import DeepSeekV32Detector
@@ -25,6 +12,7 @@ class DeepSeekV41Detector(DeepSeekV32Detector):
tool_calls_block_name = " calls"
invoke_tag_name = " invoke"
parameter_tag_name = " parameter"
strip_string_param_value: bool = False
# The encoder joins an assistant turn's content and its calls block with a
# blank line, and renders it even when there is no content.
@@ -32,72 +20,4 @@ class DeepSeekV41Detector(DeepSeekV32Detector):
think_end_token = "</think>"
def get_structural_tag_name(self) -> Optional[str]:
# xgrammar's builtin "deepseek_v4" tag hardcodes the unspaced names,
# so the V4.1 tag is assembled in get_structural_tag instead.
return None
def get_structural_tag(
self,
tools: Union[List[Tool], None] = None,
tool_choice: Union[ToolChoice, Literal["auto", "required"]] = "auto",
thinking_mode: bool = False,
parallel_tool_calls: bool = True,
) -> Optional[StructuralTag]:
"""The builtin "deepseek_v4" shape with the spaced tag names.
Bodies are JSON: xgrammar's "deepseek_xml" body style also hardcodes
the unspaced "parameter" name, and the V3.2-lineage parser accepts a
JSON body inside an invoke.
"""
tools = list(tools or [])
if isinstance(tool_choice, ToolChoice):
tools = [
tool
for tool in tools
if tool.function.name == tool_choice.function.name
]
if len(tools) != 1:
return None
if not tools:
return None
def invoke_tag(tool: Tool) -> TagFormat:
function = tool.function
schema = function.parameters if function.strict else True
if schema is None:
schema = True
return TagFormat(
begin=f'{self.invoke_start_token} name="{function.name}">',
content=JSONSchemaFormat(json_schema=schema),
end=f"{self.invoke_end_token}\n",
)
tags = [invoke_tag(tool) for tool in tools]
if isinstance(tool_choice, ToolChoice):
calls = tags[0]
elif parallel_tool_calls:
calls = TagsWithSeparatorFormat(tags=tags, separator="", at_least_one=True)
else:
calls = OrFormat(elements=tags)
block_begin = f"{self.bot_token}\n"
if tool_choice == "auto":
body = TriggeredTagsFormat(
triggers=[self.bot_token],
tags=[TagFormat(begin=block_begin, content=calls, end=self.eot_token)],
excludes=["<think>", self.think_end_token],
)
else:
body = SequenceFormat(
elements=[
ConstStringFormat(value=self.tool_calls_prefix + block_begin),
calls,
ConstStringFormat(value=self.eot_token),
]
)
if not thinking_mode:
return StructuralTag(format=body)
reasoning = TagFormat(
begin="", content=AnyTextFormat(), end=self.think_end_token
)
return StructuralTag(format=SequenceFormat(elements=[reasoning, body]))
return "deepseek_v4_1"
@@ -468,6 +468,7 @@ class KimiK2Detector(BaseFormatDetector):
tools=converted_tools,
tool_choice=converted_tool_choice,
reasoning=thinking_mode,
parallel_tool_calls=parallel_tool_calls,
)
def get_structural_tag_name(self) -> str: