[Frontend] Upgrade XGrammar to 0.2.7 and Rust structural tags to 0.3.0 (#57272)
Co-authored-by: Codex <noreply@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Sep 18, 2026 at 19:44 UTC
c58532c86b8ab821fc1d819636480b7588bce7b5
15 files changed
+57
-303
requirements/common.txt
+1
-1
@@ -26,7 +26,7 @@ lm-format-enforcer == 0.11.3
26
llguidance >= 1.7.0, < 1.8.0; platform_machine == "x86_64" or platform_machine == "arm64" or platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "s390x"
27
outlines_core == 0.2.14
28
lark == 1.2.2
29
-xgrammar >= 0.2.1, < 1.0.0; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le"
29
+xgrammar == 0.2.7; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le"
30
typing_extensions >= 4.10
31
filelock >= 3.16.1 # need to contain https://github.com/tox-dev/filelock/pull/317
32
partial-json-parser # used for parsing partial JSON outputs
requirements/test/cpu.txt
+2
-2
@@ -43,7 +43,7 @@ anyio==4.14.1
43
# sse-starlette
44
# starlette
45
# watchfiles
46
-apache-tvm-ffi==0.1.9
46
+apache-tvm-ffi==0.1.14.post0
47
# via xgrammar
48
arctic-inference==0.1.1
49
# via -r requirements/test/cuda.in
@@ -1298,7 +1298,7 @@ word2number==1.1
1298
# via lm-eval
1299
wrapt==1.17.2
1300
# via smart-open
1301
-xgrammar==0.2.3
1301
+xgrammar==0.2.7
1302
# via -r requirements/test/../common.txt
1303
xxhash==3.5.0
1304
# via
requirements/test/cuda.txt
+1
-1
@@ -1409,7 +1409,7 @@ word2number==1.1
1409
# via lm-eval
1410
wrapt==1.17.2
1411
# via smart-open
1412
-xgrammar==0.2.3
1412
+xgrammar==0.2.7
1413
# via
1414
# -c requirements/common.txt
1415
# -r requirements/test/../common.txt
requirements/test/rocm.txt
+1
-1
@@ -1360,7 +1360,7 @@ word2number==1.1
1360
# via lm-eval
1361
wrapt==2.1.2
1362
# via smart-open
1363
-xgrammar==0.2.1
1363
+xgrammar==0.2.7
1364
# via
1365
# -c requirements/common.txt
1366
# -r requirements/test/../common.txt
requirements/test/xpu.txt
+1
-1
@@ -1197,7 +1197,7 @@ werkzeug==3.1.8
1197
# via schemathesis
1198
word2number==1.1
1199
# via lm-eval
1200
-xgrammar==0.2.3
1200
+xgrammar==0.2.7
1201
# via
1202
# -c requirements/common.txt
1203
# -r requirements/test/../common.txt
rust/Cargo.lock
+2
-2
@@ -6596,9 +6596,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
6596
6597
[[package]]
6598
name = "xgrammar-structural-tag"
6599
-version = "0.2.0+xgrammar.0.2.4.dd729e7"
6599
+version = "0.3.0+xgrammar.0.2.7.82505d0"
6600
source = "registry+https://github.com/rust-lang/crates.io-index"
6601
-checksum = "d4d24c842efc3c24e9756aa426d530cbdac0980e49af223cb384e276e981ca0a"
6601
+checksum = "3864005575c2ab76057f789898f59fb9fa1378d07c5ceabc24764e012d0e90bb"
6602
dependencies = [
6603
"auto_impl",
6604
"serde",
rust/Cargo.toml
+1
-1
@@ -153,7 +153,7 @@ vllm-text = { path = "src/text" }
153
vllm-tokenizer = { path = "src/tokenizer" }
154
vllm-tracing = { path = "src/tracing" }
155
winnow = { version = "1.0.2", features = ["simd"] }
156
-xgrammar-structural-tag = "0.2.0"
156
+xgrammar-structural-tag = "0.3.0"
157
zeromq = { version = "0.6.0", default-features = false, features = [
158
"tokio-runtime",
159
"all-transport",
rust/src/parser/src/tool/deepseek_dsml/deepseek_v41.rs
+1
-3
@@ -4,8 +4,6 @@
4
use super::{DeepSeekDsmlToolParser, DsmlTokens};
5
use crate::tool::{Result, StructuralTagBuilder, Tool, ToolParser, ToolParserOutput};
6
7
-mod structural_tag;
8
-
7
/// Tool parser for DeepSeek V4.1's spaced DSML tags.
8
///
9
/// Arguments are emitted only after a full `invoke` block is parsed.
@@ -27,7 +25,7 @@ impl ToolParser for DeepSeekV41ToolParser {
25
}
26
27
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
30
- Some(&structural_tag::DeepSeekV41StructuralTagBuilder)
28
+ Some(xgrammar_structural_tag::Model::DeepSeekV41.builder())
29
}
30
31
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/structural_tag.rs
deleted
-108
@@ -1,108 +0,0 @@
1
-// SPDX-License-Identifier: Apache-2.0
2
-// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-
4
-//! Visible-text grammar for DeepSeek V4.1 tool calls.
5
-//!
6
-//! Adapted from xgrammar-structural-tag 0.2.0's DeepSeek V4 builder, with
7
-//! V4.1's spaced DSML tags and schema-unconstrained parameter values.
8
-
9
-use serde_json::Value;
10
-use xgrammar_structural_tag::Result;
11
-use xgrammar_structural_tag::builders::{
12
- StructuralTagBuilder, StructuralTagContext, StructuralTagOptions,
13
-};
14
-use xgrammar_structural_tag::format::{
15
- Format, JsonSchemaFormat, StructuralTag, TagFormat, TriggeredTagsFormat,
16
-};
17
-use xgrammar_structural_tag::tool::{BuilderToolChoice, FunctionToolParam};
18
-
19
-use super::DsmlTokens;
20
-
21
-const TOKENS: DsmlTokens = DsmlTokens::V41;
22
-
23
-pub(super) struct DeepSeekV41StructuralTagBuilder;
24
-
25
-impl StructuralTagBuilder for DeepSeekV41StructuralTagBuilder {
26
- fn build(&self, ctx: StructuralTagContext<'_>) -> Result<StructuralTag> {
27
- let tags = ctx
28
- .function_tools
29
- .iter()
30
- .map(|tool| call_tag(tool, ctx.options))
31
- .collect::<Vec<_>>();
32
- let excludes = if ctx.options.exclude_special_tokens {
33
- &["<think>", "</think>"][..]
34
- } else {
35
- &[]
36
- };
37
- let format = match ctx.tool_choice {
38
- BuilderToolChoice::Auto if tags.is_empty() => Format::any_text_excluding(excludes),
39
- BuilderToolChoice::Auto => Format::TriggeredTags(
40
- TriggeredTagsFormat::new(
41
- &[TOKENS.tool_calls_start],
42
- vec![TagFormat::new(
43
- format!("{}\n", TOKENS.tool_calls_start),
44
- Format::tags_with_separator(tags, "", true, false),
45
- TOKENS.tool_calls_end,
46
- )],
47
- )
48
- .with_excludes(excludes),
49
- ),
50
- BuilderToolChoice::Forced | BuilderToolChoice::Required => Format::sequence(vec![
51
- Format::const_string(format!("\n\n{}\n", TOKENS.tool_calls_start)),
52
- Format::tags_with_separator(
53
- tags,
54
- "",
55
- true,
56
- ctx.tool_choice == BuilderToolChoice::Forced,
57
- ),
58
- Format::const_string(TOKENS.tool_calls_end),
59
- ]),
60
- };
61
- // Reasoning boundaries remain owned by the engine reasoner; serving
62
- // calls this visible-text builder with reasoning=false.
63
- Ok(StructuralTag::new(format))
64
- }
65
-}
66
-
67
-fn call_tag(tool: &FunctionToolParam, options: StructuralTagOptions) -> TagFormat {
68
- // TODO: enforce tool.parameters and strict schema semantics once V4.1
69
- // argument conversion is available. This builder intentionally ignores
70
- // parameters and strict: it constrains tool names and DSML/value syntax
71
- // only. Parameter names, presence, uniqueness and value schemas remain
72
- // unconstrained, including for strict=true. Serving still uses strict to
73
- // decide whether auto tool choice activates a grammar.
74
- TagFormat::new(
75
- format!("{} name=\"{}\">\n", TOKENS.invoke_start, tool.function.name),
76
- Format::star(parameter(options)),
77
- format!("{}\n", TOKENS.invoke_end),
78
- )
79
-}
80
-
81
-fn parameter(options: StructuralTagOptions) -> Format {
82
- Format::tag(
83
- format!("{} name=\"", TOKENS.parameter_start),
84
- Format::sequence(vec![
85
- Format::regex(r#"[^"]+"#),
86
- Format::const_string("\" string=\""),
87
- Format::or(vec![
88
- Format::sequence(vec![
89
- Format::const_string("true\">"),
90
- Format::any_text_excluding(&[
91
- TOKENS.parameter_end,
92
- TOKENS.invoke_end,
93
- TOKENS.tool_calls_end,
94
- ]),
95
- ]),
96
- Format::sequence(vec![
97
- Format::const_string("false\">"),
98
- Format::JsonSchema(
99
- JsonSchemaFormat::new(Value::Bool(true))
100
- .with_any_order(options.any_order)
101
- .with_max_whitespace_cnt(options.max_whitespace_cnt),
102
- ),
103
- ]),
104
- ]),
105
- ]),
106
- format!("{}\n", TOKENS.parameter_end),
107
- )
108
-}
rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/tests.rs
+8
-2
@@ -102,7 +102,7 @@ fn v4_tags_remain_text_in_v41_dialect() {
102
}
103
104
#[test]
105
-fn structural_tag_ignores_parameter_schemas_and_strict() {
105
+fn structural_tag_respects_parameter_schemas_and_strict() {
106
let parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
107
let builder = parser.structural_tag_builder().unwrap();
108
let options = StructuralTagOptions::default().with_reasoning(false);
@@ -128,7 +128,13 @@ fn structural_tag_ignores_parameter_schemas_and_strict() {
128
] {
129
let mut function = FunctionDefinition::new("lookup").with_parameters(parameters);
130
function.strict = strict;
131
- assert_eq!(build(function), expected);
131
+ let tag = build(function);
132
+ if strict == Some(false) {
133
+ assert_eq!(tag, expected);
134
+ } else {
135
+ assert_ne!(tag, expected);
136
+ }
137
+ assert!(tag.to_json_string().unwrap().contains("deepseek_v4_1_xml"));
138
}
139
}
140
}
rust/src/parser/src/tool/hy/structural_tag.rs
+6
-6
@@ -219,7 +219,7 @@ mod tests {
219
ToolChoice::required(),
220
);
221
222
- expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>\n"},{"type":"tags_with_separator","tags":[{"begin":"<tool_call:opensource>get_weather<tool_sep:opensource>\n","content":{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"city"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<tool_sep:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"]},{"type":"const_string","value":"</arg_value:opensource>"},{"type":"const_string","value":"\n"}]},{"type":"star","content":{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"days"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<tool_sep:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"]},{"type":"const_string","value":"</arg_value:opensource>"},{"type":"const_string","value":"\n"}]}}]},"end":"</tool_call:opensource>"}],"separator":"\n","at_least_one":true,"stop_after_first":false},{"type":"const_string","value":"\n</tool_calls:opensource>"}]}}"#]].assert_eq(&tag.to_json_string().unwrap());
222
+ expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>\n"},{"type":"tags_with_separator","tags":[{"type":"tag","begin":"<tool_call:opensource>get_weather<tool_sep:opensource>\n","content":{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"city"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<tool_sep:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value:opensource>"},{"type":"const_string","value":"\n"}]},{"type":"star","content":{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"days"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<tool_sep:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value:opensource>"},{"type":"const_string","value":"\n"}]}}]},"end":"</tool_call:opensource>"}],"separator":"\n","at_least_one":true,"stop_after_first":false},{"type":"const_string","value":"\n</tool_calls:opensource>"}]}}"#]].assert_eq(&tag.to_json_string().unwrap());
223
}
224
225
#[test]
@@ -238,7 +238,7 @@ mod tests {
238
ToolChoice::required(),
239
);
240
241
- expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>"},{"type":"tags_with_separator","tags":[{"begin":"<tool_call:opensource>get_weather","content":{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"city"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"]},{"type":"const_string","value":"</arg_value:opensource>"}]}]},"end":"</tool_call:opensource>"}],"separator":"","at_least_one":true,"stop_after_first":false},{"type":"const_string","value":"</tool_calls:opensource>"}]}}"#]].assert_eq(&tag.to_json_string().unwrap());
241
+ expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>"},{"type":"tags_with_separator","tags":[{"type":"tag","begin":"<tool_call:opensource>get_weather","content":{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"city"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value:opensource>"}]}]},"end":"</tool_call:opensource>"}],"separator":"","at_least_one":true,"stop_after_first":false},{"type":"const_string","value":"</tool_calls:opensource>"}]}}"#]].assert_eq(&tag.to_json_string().unwrap());
242
}
243
244
#[test]
@@ -259,7 +259,7 @@ mod tests {
259
);
260
let value = serde_json::to_value(tag).unwrap();
261
262
- expect![[r#"{"type":"sequence","elements":[{"type":"star","content":{"type":"or","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"query"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"]},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]},{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"limit"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"]},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]}]}}]}"#]].assert_eq(
262
+ expect![[r#"{"type":"sequence","elements":[{"type":"star","content":{"type":"or","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"query"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]},{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"limit"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]}]}}]}"#]].assert_eq(
263
&serde_json::to_string(
264
&value["format"]["elements"][1]["tags"][0]["content"],
265
)
@@ -276,8 +276,8 @@ mod tests {
276
let auto = build("", &tools, ToolChoice::auto()).to_json_string().unwrap();
277
let forced = build("", &tools, ToolChoice::function("lookup")).to_json_string().unwrap();
278
279
- expect![[r#"{"type":"structural_tag","format":{"type":"triggered_tags","triggers":["<tool_calls>"],"tags":[{"begin":"<tool_calls>\n","content":{"type":"tags_with_separator","tags":[{"begin":"<tool_call>search<tool_sep>\n","content":{"type":"any_text","excludes":[]},"end":"</tool_call>"},{"begin":"<tool_call>lookup<tool_sep>\n","content":{"type":"any_text","excludes":[]},"end":"</tool_call>"}],"separator":"\n","at_least_one":true,"stop_after_first":false},"end":"\n</tool_calls>"}],"at_least_one":false,"stop_after_first":false,"excludes":[]}}"#]].assert_eq(&auto);
280
- expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls>\n"},{"type":"tag","begin":"<tool_call>lookup<tool_sep>\n","content":{"type":"any_text","excludes":[]},"end":"</tool_call>"},{"type":"const_string","value":"\n</tool_calls>"}]}}"#]].assert_eq(&forced);
279
+ expect![[r#"{"type":"structural_tag","format":{"type":"triggered_tags","triggers":["<tool_calls>"],"tags":[{"type":"tag","begin":"<tool_calls>\n","content":{"type":"tags_with_separator","tags":[{"type":"tag","begin":"<tool_call>search<tool_sep>\n","content":{"type":"any_text","excludes":[],"max_tokens":null,"max_chars":null},"end":"</tool_call>"},{"type":"tag","begin":"<tool_call>lookup<tool_sep>\n","content":{"type":"any_text","excludes":[],"max_tokens":null,"max_chars":null},"end":"</tool_call>"}],"separator":"\n","at_least_one":true,"stop_after_first":false},"end":"\n</tool_calls>"}],"at_least_one":false,"stop_after_first":false,"excludes":[]}}"#]].assert_eq(&auto);
280
+ expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls>\n"},{"type":"tag","begin":"<tool_call>lookup<tool_sep>\n","content":{"type":"any_text","excludes":[],"max_tokens":null,"max_chars":null},"end":"</tool_call>"},{"type":"const_string","value":"\n</tool_calls>"}]}}"#]].assert_eq(&forced);
281
}
282
283
#[test]
@@ -314,7 +314,7 @@ mod tests {
314
);
315
let value = serde_json::to_value(tag).unwrap();
316
317
- expect![[r#"{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"query"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"]},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]},{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"tenant"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"]},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]}]}"#]].assert_eq(
317
+ expect![[r#"{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"query"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]},{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"tenant"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]}]}"#]].assert_eq(
318
&serde_json::to_string(
319
&value["format"]["elements"][1]["tags"][0]["content"],
320
)
rust/src/parser/src/unified/kimi_k3/structural_tag.rs
+17
-8
@@ -6,14 +6,14 @@
6
use serde_json::{Map, Value};
7
use xgrammar_structural_tag::Result;
8
use xgrammar_structural_tag::builders::{
9
- StructuralTagBuilder, StructuralTagContext, StructuralTagOptions,
9
+ ReasoningMode, StructuralTagBuilder, StructuralTagContext, StructuralTagOptions,
10
};
11
use xgrammar_structural_tag::format::{Format, JsonSchemaFormat, StructuralTag, TagFormat};
12
use xgrammar_structural_tag::tool::{BuilderToolChoice, FunctionToolParam, function_parameters};
13
14
use super::{
15
ARG_CLOSE, CALL_CLOSE, CLOSE, END_OF_MSG, JSON_CLOSE, JSON_OPEN, MESSAGE_CLOSE, OPEN,
16
- RESPONSE_CLOSE, RESPONSE_OPEN, SEP, THINK_CLOSE, TOOLS_CLOSE, TOOLS_OPEN,
16
+ RESPONSE_CLOSE, RESPONSE_OPEN, SEP, THINK_CLOSE, THINK_OPEN, TOOLS_CLOSE, TOOLS_OPEN,
17
};
18
19
pub(super) static KIMI_K3_STRUCTURAL_TAG_BUILDER: KimiK3StructuralTagBuilder =
@@ -50,14 +50,23 @@ impl StructuralTagBuilder for KimiK3StructuralTagBuilder {
50
}
51
}
52
53
-fn response_prefix(reasoning: bool) -> Vec<Format> {
53
+fn response_prefix(reasoning: ReasoningMode) -> Vec<Format> {
54
let mut elements = Vec::new();
55
- if reasoning {
56
- elements.push(Format::tag(
57
- "",
55
+ if reasoning != ReasoningMode::Disabled {
56
+ let prefix = Format::tag(
57
+ if reasoning == ReasoningMode::Auto {
58
+ THINK_OPEN
59
+ } else {
60
+ ""
61
+ },
62
Format::any_text_excluding(&[THINK_CLOSE, END_OF_MSG]),
63
THINK_CLOSE,
60
- ));
64
+ );
65
+ elements.push(if reasoning == ReasoningMode::Auto {
66
+ Format::optional(prefix)
67
+ } else {
68
+ prefix
69
+ });
70
elements.push(Format::const_string(RESPONSE_OPEN));
71
} else {
72
elements.push(Format::optional(Format::const_string(RESPONSE_OPEN)));
@@ -386,7 +395,7 @@ mod tests {
395
)
396
.unwrap();
397
389
- expect![[r##"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"optional","content":{"type":"const_string","value":"<|open|>response<|sep|>"}},{"type":"tag","begin":"","content":{"type":"any_text","excludes":["<|open|>","<|close|>","<|end_of_msg|>"]},"end":"<|close|>response<|sep|>"},{"type":"tag","begin":"<|open|>tools<|sep|>","content":{"type":"tags_with_separator","tags":[{"begin":"<|open|>call tool=\"get_weather\" index=\"","content":{"type":"sequence","elements":[{"type":"regex","pattern":"[1-9][0-9]*"},{"type":"const_string","value":"\"<|sep|>"},{"type":"or","elements":[{"type":"plus","content":{"type":"or","elements":[{"type":"tag","begin":"<|open|>argument key=\"unit\" type=\"string\"<|sep|>","content":{"type":"or","elements":[{"type":"const_string","value":"celsius"},{"type":"const_string","value":"fahrenheit"}]},"end":"<|close|>argument<|sep|>"},{"type":"tag","begin":"<|open|>argument key=\"place\" type=\"object\"<|sep|>","content":{"type":"json_schema","json_schema":{"$ref":"#/$defs/place","type":"object","$defs":{"place":{"type":"object","properties":{"city":{"type":"string"}}}}},"style":"json","any_order":false,"max_whitespace_cnt":null},"end":"<|close|>argument<|sep|>"}]}},{"type":"tag","begin":"<|open|>json type=\"object\"<|sep|>","content":{"type":"json_schema","json_schema":{"$defs":{"place":{"type":"object","properties":{"city":{"type":"string"}}}},"type":"object","properties":{"unit":{"type":"string","enum":["celsius","fahrenheit"]},"place":{"$ref":"#/$defs/place","type":"object"}},"required":["place"]},"style":"json","any_order":false,"max_whitespace_cnt":null},"end":"<|close|>json<|sep|>"}]}]},"end":"<|close|>call<|sep|>"}],"separator":"","at_least_one":true,"stop_after_first":false},"end":"<|close|>tools<|sep|>"},{"type":"optional","content":{"type":"const_string","value":"<|close|>message<|sep|>"}}]}}"##]].assert_eq(&tag.to_json_string().unwrap());
398
+ expect![[r##"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"optional","content":{"type":"const_string","value":"<|open|>response<|sep|>"}},{"type":"tag","begin":"","content":{"type":"any_text","excludes":["<|open|>","<|close|>","<|end_of_msg|>"],"max_tokens":null,"max_chars":null},"end":"<|close|>response<|sep|>"},{"type":"tag","begin":"<|open|>tools<|sep|>","content":{"type":"tags_with_separator","tags":[{"type":"tag","begin":"<|open|>call tool=\"get_weather\" index=\"","content":{"type":"sequence","elements":[{"type":"regex","pattern":"[1-9][0-9]*"},{"type":"const_string","value":"\"<|sep|>"},{"type":"or","elements":[{"type":"plus","content":{"type":"or","elements":[{"type":"tag","begin":"<|open|>argument key=\"unit\" type=\"string\"<|sep|>","content":{"type":"or","elements":[{"type":"const_string","value":"celsius"},{"type":"const_string","value":"fahrenheit"}]},"end":"<|close|>argument<|sep|>"},{"type":"tag","begin":"<|open|>argument key=\"place\" type=\"object\"<|sep|>","content":{"type":"json_schema","json_schema":{"$ref":"#/$defs/place","type":"object","$defs":{"place":{"type":"object","properties":{"city":{"type":"string"}}}}},"style":"json","any_order":false,"max_whitespace_cnt":null},"end":"<|close|>argument<|sep|>"}]}},{"type":"tag","begin":"<|open|>json type=\"object\"<|sep|>","content":{"type":"json_schema","json_schema":{"$defs":{"place":{"type":"object","properties":{"city":{"type":"string"}}}},"type":"object","properties":{"unit":{"type":"string","enum":["celsius","fahrenheit"]},"place":{"$ref":"#/$defs/place","type":"object"}},"required":["place"]},"style":"json","any_order":false,"max_whitespace_cnt":null},"end":"<|close|>json<|sep|>"}]}]},"end":"<|close|>call<|sep|>"}],"separator":"","at_least_one":true,"stop_after_first":false},"end":"<|close|>tools<|sep|>"},{"type":"optional","content":{"type":"const_string","value":"<|close|>message<|sep|>"}}]}}"##]].assert_eq(&tag.to_json_string().unwrap());
399
}
400
401
#[test]
tests/tool_parsers/test_structural_tag_registry.py
+13
-58
@@ -6,7 +6,7 @@ from types import SimpleNamespace
6
from unittest.mock import MagicMock
7
8
import pytest
9
-from xgrammar import Grammar, StructuralTag, builtin_structural_tag
9
+from xgrammar import Grammar, StructuralTag
10
from xgrammar.testing import _is_grammar_accept_string
11
12
from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -23,6 +23,7 @@ from vllm.tool_parsers.deepseekv31_tool_parser import DeepSeekV31ToolParser
23
from vllm.tool_parsers.deepseekv32_engine_tool_parser import (
24
DeepSeekV32EngineToolParser,
25
)
26
+from vllm.tool_parsers.deepseekv41_engine_tool_parser import DeepSeekV41EngineToolParser
27
from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
28
from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
29
from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser
@@ -83,51 +84,9 @@ def test_supported_structural_tag_models_include_vllm_builtins():
84
assert "hermes" in VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
85
86
86
-@pytest.mark.parametrize("choice", ["auto", "required", "get_weather"])
87
-def test_deepseek_v41_older_xgrammar_keeps_format_constraints(
88
- choice,
89
- sample_tools_strict,
90
- monkeypatch,
91
-):
92
- monkeypatch.setattr(
93
- builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None, raising=False
94
- )
95
- tool_choice = (
96
- ChatCompletionNamedToolChoiceParam(
97
- function=ChatCompletionNamedFunction(name=choice)
98
- )
99
- if choice == "get_weather"
100
- else choice
101
- )
102
- tag = get_model_structural_tag(
103
- "deepseek_v41", sample_tools_strict, tool_choice, reasoning=False
104
- )
105
- grammar = Grammar.from_structural_tag(tag)
106
- begin = '\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n'
107
- end = "</|DSML| invoke>\n</|DSML| calls>"
108
- parameter = (
109
- '<|DSML| parameter name="undeclared" string="false">'
110
- '{"nested": [true, null, 1.5]}</|DSML| parameter>\n'
111
- )
112
- # Required city is omitted; extra and repeated fields remain unconstrained.
113
- assert _is_grammar_accept_string(grammar, begin + end)
114
- assert _is_grammar_accept_string(grammar, begin + parameter * 2 + end)
115
- assert not _is_grammar_accept_string(
116
- grammar,
117
- begin + parameter.replace('{"nested": [true, null, 1.5]}', "invalid") + end,
118
- )
119
- assert not _is_grammar_accept_string(
120
- grammar, (begin + end).replace('name="get_weather"', 'name="unknown"')
121
- )
122
- assert not _is_grammar_accept_string(
123
- grammar, begin + parameter.replace("| parameter", "|parameter") + end
124
- )
125
- assert _is_grammar_accept_string(grammar, "Hello") == (choice == "auto")
126
-
127
-
87
def test_deepseek_v41_named_choice_emits_one_call(sample_tools):
88
tag = get_model_structural_tag(
130
- "deepseek_v41",
89
+ "deepseek_v4_1",
90
sample_tools,
91
ChatCompletionNamedToolChoiceParam(
92
function=ChatCompletionNamedFunction(name="get_weather")
@@ -147,19 +106,14 @@ def test_deepseek_v41_named_choice_emits_one_call(sample_tools):
106
grammar, "\n\n<|DSML| calls>\n" + call * 2 + "</|DSML| calls>"
107
)
108
assert (
150
- get_model_structural_tag("deepseek_v41", sample_tools, "auto", reasoning=False)
109
+ get_model_structural_tag("deepseek_v4_1", sample_tools, "auto", reasoning=False)
110
is None
111
)
112
113
155
-@pytest.mark.skipif(
156
- not hasattr(builtin_structural_tag, "get_deepseek_v4_1_structural_tag"),
157
- reason="Requires XGrammar's DeepSeek V4.1 builtin",
158
-)
114
@pytest.mark.parametrize("choice", ["auto", "required", "get_weather"])
160
-@pytest.mark.parametrize("reasoning", [False, True])
115
def test_deepseek_v41_constrains_parameters_after_reasoning(
162
- sample_tools_strict, choice, reasoning
116
+ sample_tools_strict, choice
117
):
118
"""Strict calls must enforce the schema without requiring another think close."""
119
tool_choice = (
@@ -171,7 +125,7 @@ def test_deepseek_v41_constrains_parameters_after_reasoning(
125
)
126
sample_tools_strict[0].function.parameters["additionalProperties"] = False
127
tag = get_model_structural_tag(
174
- "deepseek_v41", sample_tools_strict, tool_choice, reasoning=reasoning
128
+ "deepseek_v4_1", sample_tools_strict, tool_choice, reasoning=False
129
)
130
grammar = Grammar.from_structural_tag(tag)
131
begin = '\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n'
@@ -195,14 +149,10 @@ def test_deepseek_v41_constrains_parameters_after_reasoning(
149
assert _is_grammar_accept_string(grammar, "Hello") == (choice == "auto")
150
151
198
-@pytest.mark.skipif(
199
- not hasattr(builtin_structural_tag, "get_deepseek_v4_1_structural_tag"),
200
- reason="Requires XGrammar's DeepSeek V4.1 builtin",
201
-)
152
def test_deepseek_v41_non_strict_parallel_calls_keep_typed_dsml(sample_tools):
153
sample_tools[0].function.strict = False
154
tag = get_model_structural_tag(
205
- "deepseek_v41", sample_tools, "required", reasoning=False
155
+ "deepseek_v4_1", sample_tools, "required", reasoning=False
156
)
157
grammar = Grammar.from_structural_tag(tag)
158
call = (
@@ -571,6 +521,7 @@ def test_get_model_structural_tag_supports_named_tool_choice(
521
(DeepSeekV31ToolParser, "deepseek_v3_1"),
522
(DeepSeekV32EngineToolParser, "deepseek_v3_2"),
523
(DeepSeekV4EngineToolParser, "deepseek_v4"),
524
+ (DeepSeekV41EngineToolParser, "deepseek_v4_1"),
525
(Glm47MoeModelToolParser, "glm_4_7"),
526
(Hermes2ProToolParser, "hermes"),
527
(KimiK2ToolParser, "kimi"),
@@ -638,7 +589,11 @@ def test_get_structural_tag_disables_reasoning(
589
assert captured == [False]
590
591
592
+@pytest.mark.parametrize(
593
+ "parser_cls", [Qwen3EngineToolParser, DeepSeekV41EngineToolParser]
594
+)
595
def test_unified_parser_get_structural_tag_disables_reasoning(
596
+ parser_cls,
597
monkeypatch: pytest.MonkeyPatch,
598
sample_tools_strict: list[ChatCompletionToolsParam],
599
):
@@ -654,7 +609,7 @@ def test_unified_parser_get_structural_tag_disables_reasoning(
609
)
610
611
class TestParser(DelegatingParser):
657
- tool_parser_cls = Qwen3EngineToolParser
612
+ tool_parser_cls = parser_cls
613
614
request = ChatCompletionRequest(
615
messages=[],
vllm/tool_parsers/deepseekv41_engine_tool_parser.py
+1
-1
@@ -5,4 +5,4 @@ from vllm.parser.engine.registered_adapters import DeepSeekV41ParserToolAdapter
5
6
7
class DeepSeekV41EngineToolParser(DeepSeekV41ParserToolAdapter): # type: ignore[valid-type, misc]
8
- structural_tag_model = "deepseek_v41"
8
+ structural_tag_model = "deepseek_v4_1"
vllm/tool_parsers/structural_tag_registry.py
+2
-108
@@ -10,7 +10,6 @@ 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
13
from xgrammar import get_model_structural_tag as get_xgrammar_model_structural_tag
14
from xgrammar.openai_tool_call_schema import (
15
BuiltinToolParam,
@@ -71,11 +70,10 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
70
"deepseek_v3_2",
71
"glm_4_7",
72
"deepseek_v4",
73
+ "deepseek_v4_1",
74
}
75
)
76
-VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
77
- {"deepseek_v41", "hermes", "hy_v4", "kimi_k3"}
78
-)
76
+VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3"})
77
SUPPORTED_STRUCTURAL_TAG_MODELS = (
78
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
79
)
@@ -270,110 +268,6 @@ def get_function_parameters(function) -> dict[str, Any] | bool:
268
return function.parameters if function.parameters is not None else True
269
270
273
-_V41_CALLS_START = "<|DSML| calls>"
274
-_V41_CALLS_END = "</|DSML| calls>"
275
-_V41_INVOKE_END = "</|DSML| invoke>"
276
-_V41_PARAMETER_END = "</|DSML| parameter>"
277
-
278
-
279
-@register_vllm_structural_tag("deepseek_v41")
280
-def get_deepseek_v41_structural_tag(
281
- tools: list[FunctionToolParam],
282
- builtin_tools: list[BuiltinToolParam],
283
- tool_choice: SimplifiedToolChoice,
284
- reasoning: bool,
285
- token_suffix: str = "",
286
-) -> StructuralTag:
287
- # Serving enables this visible-text grammar after the reasoning boundary.
288
- builder = getattr(
289
- xgrammar_builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None
290
- )
291
- if builder is not None:
292
- return builder(
293
- tools=tools,
294
- builtin_tools=builtin_tools,
295
- tool_choice=tool_choice,
296
- reasoning="disabled",
297
- )
298
-
299
- del builtin_tools, reasoning, token_suffix
300
-
301
- # Compatibility with xgrammar releases without the V4.1 builtin. This constrains
302
- # tool names and DSML/value syntax only. Parameter names, presence, uniqueness
303
- # and value schemas remain unconstrained, including for strict=true. The request
304
- # layer still uses strict to decide whether auto tool choice activates a grammar.
305
- parameter = TagFormat(
306
- begin='<|DSML| parameter name="',
307
- content=SequenceFormat(
308
- elements=[
309
- RegexFormat(pattern=r'[^"]+'),
310
- ConstStringFormat(value='" string="'),
311
- OrFormat(
312
- elements=[
313
- SequenceFormat(
314
- elements=[
315
- ConstStringFormat(value='true">'),
316
- AnyTextFormat(
317
- excludes=[
318
- _V41_PARAMETER_END,
319
- _V41_INVOKE_END,
320
- _V41_CALLS_END,
321
- ]
322
- ),
323
- ]
324
- ),
325
- SequenceFormat(
326
- elements=[
327
- ConstStringFormat(value='false">'),
328
- JSONSchemaFormat(json_schema=True),
329
- ]
330
- ),
331
- ]
332
- ),
333
- ]
334
- ),
335
- end=f"{_V41_PARAMETER_END}\n",
336
- )
337
- calls = TagsWithSeparatorFormat(
338
- tags=[
339
- TagFormat(
340
- begin=f'<|DSML| invoke name="{tool.function.name}">\n',
341
- content=StarFormat(content=parameter),
342
- end=f"{_V41_INVOKE_END}\n",
343
- )
344
- for tool in tools
345
- ],
346
- separator="",
347
- at_least_one=True,
348
- stop_after_first=tool_choice == "forced",
349
- )
350
- if tool_choice == "auto":
351
- return StructuralTag(
352
- format=TriggeredTagsFormat(
353
- triggers=[_V41_CALLS_START],
354
- tags=[
355
- TagFormat(
356
- begin=f"{_V41_CALLS_START}\n",
357
- content=calls,
358
- end=_V41_CALLS_END,
359
- )
360
- ],
361
- excludes=["<think>", "</think>"],
362
- )
363
- if tools
364
- else AnyTextFormat(excludes=["<think>", "</think>"])
365
- )
366
- return StructuralTag(
367
- format=SequenceFormat(
368
- elements=[
369
- ConstStringFormat(value=f"\n\n{_V41_CALLS_START}\n"),
370
- calls,
371
- ConstStringFormat(value=_V41_CALLS_END),
372
- ]
373
- )
374
- )
375
-
376
-
271
def _hermes_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
272
arguments_field_prefix = '", "arguments": '
273
formats = [