[Rust Frontend] Integrate `xgrammar-structural-tag` for `strict` and `required` tool calling (#46057)
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jun 24, 2026 at 10:46 UTC
4ed8eaafb05f8c934b180c14bf1dc450eba7b12d
23 files changed
+466
-81
rust/Cargo.lock
+26
@@ -316,6 +316,17 @@ version = "1.1.2"
316
source = "registry+https://github.com/rust-lang/crates.io-index"
317
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
318
319
+[[package]]
320
+name = "auto_impl"
321
+version = "1.3.0"
322
+source = "registry+https://github.com/rust-lang/crates.io-index"
323
+checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7"
324
+dependencies = [
325
+ "proc-macro2",
326
+ "quote",
327
+ "syn 2.0.117",
328
+]
329
+
330
[[package]]
331
name = "autocfg"
332
version = "1.5.0"
@@ -5726,6 +5737,7 @@ dependencies = [
5737
"vllm-text",
5738
"vllm-tokenizer",
5739
"vllm-tool-parser",
5740
+ "xgrammar-structural-tag",
5741
"zeromq",
5742
]
5743
@@ -5981,6 +5993,7 @@ dependencies = [
5993
"thiserror-ext",
5994
"tool-parser",
5995
"winnow",
5996
+ "xgrammar-structural-tag",
5997
]
5998
5999
[[package]]
@@ -6582,6 +6595,19 @@ version = "0.6.2"
6595
source = "registry+https://github.com/rust-lang/crates.io-index"
6596
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
6597
6598
+[[package]]
6599
+name = "xgrammar-structural-tag"
6600
+version = "0.1.0+xgrammar.0.2.2.4d145cc"
6601
+source = "registry+https://github.com/rust-lang/crates.io-index"
6602
+checksum = "2436dea2393d55a3b188588aa300c5a8afe8f45a77da52c611fb4498a6c876e6"
6603
+dependencies = [
6604
+ "auto_impl",
6605
+ "serde",
6606
+ "serde_json",
6607
+ "strum",
6608
+ "thiserror 2.0.18",
6609
+]
6610
+
6611
[[package]]
6612
name = "y4m"
6613
version = "0.8.0"
rust/Cargo.toml
+1
@@ -123,6 +123,7 @@ vllm-text = { path = "src/text" }
123
vllm-tokenizer = { path = "src/tokenizer" }
124
vllm-tool-parser = { path = "src/tool-parser" }
125
winnow = "1.0.2"
126
+xgrammar-structural-tag = "0.1.0"
127
zeromq = { version = "0.6.0", default-features = false, features = [
128
"tokio-runtime",
129
"all-transport",
rust/src/chat/Cargo.toml
+1
@@ -34,6 +34,7 @@ vllm-reasoning-parser.workspace = true
34
vllm-text.workspace = true
35
vllm-tokenizer.workspace = true
36
vllm-tool-parser.workspace = true
37
+xgrammar-structural-tag.workspace = true
38
39
[dev-dependencies]
40
anyhow.workspace = true
rust/src/chat/src/error.rs
+2
@@ -64,6 +64,8 @@ pub enum Error {
64
StreamClosedBeforeTerminalOutput { request_id: String },
65
#[error("tool call stream state is inconsistent: {message}")]
66
ToolCallStreamInvariant { message: String },
67
+ #[error("failed to build structural tag: {message}")]
68
+ StructuralTag { message: String },
69
#[error(transparent)]
70
Text(#[from] vllm_text::Error),
71
#[error(transparent)]
rust/src/chat/src/output/default/mod.rs
+6
-3
@@ -1,6 +1,7 @@
1
//! Default output processing pipeline.
2
3
mod reasoning;
4
+mod structural_tag;
5
mod tool;
6
7
use std::sync::Once;
@@ -11,6 +12,7 @@ use trait_set::trait_set;
12
use vllm_text::tokenizer::DynTokenizer;
13
14
use self::reasoning::reasoning_event_stream;
15
+use self::structural_tag::apply_structural_tag_constraint;
16
use self::tool::tool_event_stream;
17
use super::structured::structured_chat_event_stream;
18
use crate::error::Result;
@@ -21,7 +23,7 @@ use crate::output::{
23
use crate::parser::ParserSelection;
24
use crate::parser::reasoning::{ReasoningParser, ReasoningParserFactory};
25
use crate::parser::tool::{ToolParser, ToolParserFactory};
24
-use crate::request::{ChatRequest, ChatToolChoice};
26
+use crate::request::ChatRequest;
27
use crate::{Error, Result as ChatResult};
28
29
trait_set! {
@@ -54,8 +56,7 @@ impl DefaultChatOutputProcessor {
56
tool_call_parser: &ParserSelection,
57
reasoning_parser: &ParserSelection,
58
) -> ChatResult<Self> {
57
- let tool_parsing_enabled =
58
- matches!(request.tool_choice, ChatToolChoice::Auto) && !request.tools.is_empty();
59
+ let tool_parsing_enabled = request.tool_parsing_enabled();
60
let tool_parser = if tool_parsing_enabled {
61
Some(Self::resolve_tool_parser(
62
request,
@@ -115,6 +116,8 @@ impl DefaultChatOutputProcessor {
116
request.decode_options.skip_special_tokens = false;
117
}
118
119
+ apply_structural_tag_constraint(request, parser.as_ref())?;
120
+
121
TOOL_PARSER_LOG_ONCE.call_once(|| info!(parser_name, "using tool parser"));
122
Ok(parser)
123
}
rust/src/chat/src/output/default/structural_tag.rs
new
+257
@@ -0,0 +1,257 @@
1
+//! Applies xgrammar structural-tag constraints for strict tool calling.
2
+
3
+use thiserror_ext::AsReport;
4
+use vllm_engine_core_client::protocol::{StructuredOutputBackend, StructuredOutputsParams};
5
+use xgrammar_structural_tag::{
6
+ FunctionDefinition, FunctionToolParam, ToolChoice as StructuralTagToolChoice, ToolParam,
7
+ build_structural_tag,
8
+};
9
+
10
+use crate::parser::tool::ToolParser;
11
+use crate::request::{ChatRequest, ChatToolChoice};
12
+use crate::{Error, Result as ChatResult};
13
+
14
+/// Apply structural tag constraints to the request based on the tool parser's structural tag
15
+/// support and the request's tool choice.
16
+pub(super) fn apply_structural_tag_constraint(
17
+ request: &mut ChatRequest,
18
+ parser: &dyn ToolParser,
19
+) -> ChatResult<()> {
20
+ let Some(model) = parser.structural_tag_model() else {
21
+ return Ok(());
22
+ };
23
+ let Some(tool_choice) = structural_tag_tool_choice(request) else {
24
+ return Ok(());
25
+ };
26
+
27
+ let tools = request
28
+ .tools
29
+ .iter()
30
+ .map(|tool| {
31
+ ToolParam::Function(FunctionToolParam::new(FunctionDefinition {
32
+ name: tool.name.clone(),
33
+ description: tool.description.clone(),
34
+ parameters: Some(tool.parameters.clone()),
35
+ strict: tool.strict,
36
+ }))
37
+ })
38
+ .collect::<Vec<_>>();
39
+
40
+ let structural_tag = build_structural_tag(model, &tools, tool_choice, false)
41
+ .and_then(|tag| tag.to_json_string())
42
+ .map_err(|error| Error::StructuralTag {
43
+ message: error.to_report_string(),
44
+ })?;
45
+
46
+ // Overwrite any existing structured output settings with the structural tag constraint.
47
+ request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
48
+ structural_tag: Some(structural_tag),
49
+ backend: StructuredOutputBackend::Xgrammar,
50
+ ..Default::default()
51
+ });
52
+
53
+ Ok(())
54
+}
55
+
56
+/// Resolve the tool choice used for [`xgrammar_structural_tag`] based on the request.
57
+///
58
+/// Returns `None` if no structural tag constraints should be applied.
59
+fn structural_tag_tool_choice(request: &ChatRequest) -> Option<StructuralTagToolChoice> {
60
+ if request.tools.is_empty() {
61
+ return None;
62
+ }
63
+
64
+ match &request.tool_choice {
65
+ // For `Auto`, only apply the structural tag if there's at least one strict tool.
66
+ ChatToolChoice::Auto if request.tools.iter().any(|tool| tool.strict == Some(true)) => {
67
+ Some(StructuralTagToolChoice::auto())
68
+ }
69
+ ChatToolChoice::Auto | ChatToolChoice::None => None,
70
+
71
+ ChatToolChoice::Required => Some(StructuralTagToolChoice::required()),
72
+ ChatToolChoice::Function { name } => Some(StructuralTagToolChoice::function(name.clone())),
73
+ }
74
+}
75
+
76
+#[cfg(test)]
77
+mod tests {
78
+ use serde_json::{Value, json};
79
+ use vllm_engine_core_client::protocol::{StructuredOutputBackend, StructuredOutputsParams};
80
+ use vllm_tool_parser::{Qwen3CoderToolParser, Tool};
81
+
82
+ use super::*;
83
+
84
+ fn chat_tool(name: &str, strict: Option<bool>) -> Tool {
85
+ Tool {
86
+ name: name.to_string(),
87
+ description: None,
88
+ parameters: json!({
89
+ "type": "object",
90
+ "properties": {
91
+ "query": { "type": "string" }
92
+ },
93
+ "required": ["query"]
94
+ }),
95
+ strict,
96
+ }
97
+ }
98
+
99
+ fn qwen3_coder_parser(tools: &[Tool]) -> Box<dyn ToolParser> {
100
+ Qwen3CoderToolParser::create(tools).expect("Qwen3 Coder parser should build")
101
+ }
102
+
103
+ fn request(tool_choice: ChatToolChoice, tools: Vec<Tool>) -> ChatRequest {
104
+ ChatRequest {
105
+ tool_choice,
106
+ tools,
107
+ ..ChatRequest::for_test()
108
+ }
109
+ }
110
+
111
+ fn structural_tag_value(request: &ChatRequest) -> Value {
112
+ let params = request
113
+ .sampling_params
114
+ .structured_outputs
115
+ .as_ref()
116
+ .expect("structured outputs should be set");
117
+ assert_eq!(params.backend, StructuredOutputBackend::Xgrammar);
118
+ serde_json::from_str(
119
+ params.structural_tag.as_deref().expect("structural_tag should be set"),
120
+ )
121
+ .expect("structural_tag should be valid JSON")
122
+ }
123
+
124
+ fn structured_outputs(request: &ChatRequest) -> &StructuredOutputsParams {
125
+ request
126
+ .sampling_params
127
+ .structured_outputs
128
+ .as_ref()
129
+ .expect("structured outputs should be set")
130
+ }
131
+
132
+ #[test]
133
+ fn auto_strict_tool_choice_builds_structural_tag() {
134
+ let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
135
+ let parser = qwen3_coder_parser(&request.tools);
136
+
137
+ apply_structural_tag_constraint(&mut request, parser.as_ref())
138
+ .expect("structural tag should build");
139
+
140
+ let tag = structural_tag_value(&request);
141
+ assert_eq!(tag["type"], "structural_tag");
142
+ assert!(tag.to_string().contains("search"));
143
+ }
144
+
145
+ #[test]
146
+ fn auto_non_strict_tool_choice_skips_structural_tag() {
147
+ let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", None)]);
148
+ let parser = qwen3_coder_parser(&request.tools);
149
+
150
+ apply_structural_tag_constraint(&mut request, parser.as_ref())
151
+ .expect("structural tag decision should succeed");
152
+
153
+ assert!(request.sampling_params.structured_outputs.is_none());
154
+ }
155
+
156
+ #[test]
157
+ fn auto_strict_tool_choice_overwrites_existing_json_guidance() {
158
+ let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
159
+ request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
160
+ json: Some(json!({"type": "object"})),
161
+ backend: StructuredOutputBackend::Xgrammar,
162
+ ..Default::default()
163
+ });
164
+ let parser = qwen3_coder_parser(&request.tools);
165
+
166
+ apply_structural_tag_constraint(&mut request, parser.as_ref())
167
+ .expect("structural tag should build");
168
+
169
+ let params = structured_outputs(&request);
170
+ assert!(params.json.is_none());
171
+ assert!(params.structural_tag.is_some());
172
+ let tag = structural_tag_value(&request);
173
+ assert_eq!(tag["type"], "structural_tag");
174
+ assert!(tag.to_string().contains("search"));
175
+ }
176
+
177
+ #[test]
178
+ fn required_tool_choice_builds_structural_tag_without_strict_tools() {
179
+ let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
180
+ let parser = qwen3_coder_parser(&request.tools);
181
+
182
+ apply_structural_tag_constraint(&mut request, parser.as_ref())
183
+ .expect("structural tag should build");
184
+
185
+ let tag = structural_tag_value(&request);
186
+ assert_eq!(tag["type"], "structural_tag");
187
+ assert!(tag.to_string().contains("search"));
188
+ }
189
+
190
+ #[test]
191
+ fn required_tool_choice_overwrites_existing_json_object_guidance() {
192
+ let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
193
+ request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
194
+ json_object: Some(true),
195
+ backend: StructuredOutputBackend::Xgrammar,
196
+ ..Default::default()
197
+ });
198
+ let parser = qwen3_coder_parser(&request.tools);
199
+
200
+ apply_structural_tag_constraint(&mut request, parser.as_ref())
201
+ .expect("structural tag should build");
202
+
203
+ let params = structured_outputs(&request);
204
+ assert!(params.json_object.is_none());
205
+ assert!(params.structural_tag.is_some());
206
+ let tag = structural_tag_value(&request);
207
+ assert_eq!(tag["type"], "structural_tag");
208
+ assert!(tag.to_string().contains("search"));
209
+ }
210
+
211
+ #[test]
212
+ fn named_tool_choice_builds_structural_tag_for_named_tool_only() {
213
+ let mut request = request(
214
+ ChatToolChoice::Function {
215
+ name: "lookup".to_string(),
216
+ },
217
+ vec![chat_tool("search", None), chat_tool("lookup", None)],
218
+ );
219
+ let parser = qwen3_coder_parser(&request.tools);
220
+
221
+ apply_structural_tag_constraint(&mut request, parser.as_ref())
222
+ .expect("structural tag should build");
223
+
224
+ let tag = structural_tag_value(&request).to_string();
225
+ assert!(tag.contains("lookup"));
226
+ assert!(!tag.contains("search"));
227
+ }
228
+
229
+ #[test]
230
+ fn none_tool_choice_skips_structural_tag() {
231
+ let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
232
+ let parser = qwen3_coder_parser(&request.tools);
233
+
234
+ apply_structural_tag_constraint(&mut request, parser.as_ref())
235
+ .expect("structural tag decision should succeed");
236
+
237
+ assert!(request.sampling_params.structured_outputs.is_none());
238
+ }
239
+
240
+ #[test]
241
+ fn none_tool_choice_preserves_existing_json_object_guidance() {
242
+ let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
243
+ request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
244
+ json_object: Some(true),
245
+ backend: StructuredOutputBackend::Xgrammar,
246
+ ..Default::default()
247
+ });
248
+ let parser = qwen3_coder_parser(&request.tools);
249
+
250
+ apply_structural_tag_constraint(&mut request, parser.as_ref())
251
+ .expect("structural tag decision should succeed");
252
+
253
+ let params = structured_outputs(&request);
254
+ assert_eq!(params.json_object, Some(true));
255
+ assert!(params.structural_tag.is_none());
256
+ }
257
+}
rust/src/chat/src/request.rs
+7
-3
@@ -382,12 +382,16 @@ impl ChatOptions {
382
}
383
384
/// Tool-choice semantics supported by `vllm-chat`.
385
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
385
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
386
#[serde(rename_all = "snake_case")]
387
pub enum ChatToolChoice {
388
- Auto,
388
#[default]
389
None,
390
+ Auto,
391
+ Required,
392
+ Function {
393
+ name: String,
394
+ },
395
}
396
397
/// One chat request ready to be rendered into a prompt and lowered into a
@@ -486,7 +490,7 @@ impl ChatRequest {
490
/// Return true if this request should enable tool parsing based on the tool
491
/// choice and tool list.
492
pub(crate) fn tool_parsing_enabled(&self) -> bool {
489
- matches!(self.tool_choice, ChatToolChoice::Auto) && !self.tools.is_empty()
493
+ !matches!(self.tool_choice, ChatToolChoice::None) && !self.tools.is_empty()
494
}
495
496
/// Return the request-level thinking toggle when explicitly requested.
rust/src/server/src/routes/openai/chat_completions/convert.rs
+75
@@ -361,6 +361,13 @@ fn convert_tool_choice(tool_choice: Option<&ToolChoice>) -> Result<ChatToolChoic
361
match tool_choice {
362
None | Some(ToolChoice::Value(ToolChoiceValue::Auto)) => Ok(ChatToolChoice::Auto),
363
Some(ToolChoice::Value(ToolChoiceValue::None)) => Ok(ChatToolChoice::None),
364
+ Some(ToolChoice::Value(ToolChoiceValue::Required)) => Ok(ChatToolChoice::Required),
365
+ Some(ToolChoice::Function {
366
+ tool_type,
367
+ function,
368
+ }) if tool_type == "function" => Ok(ChatToolChoice::Function {
369
+ name: function.name.clone(),
370
+ }),
371
_ => bail_invalid_request!("tool_choice={:?} is not supported yet.", tool_choice),
372
}
373
}
@@ -962,6 +969,74 @@ mod tests {
969
assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::None);
970
}
971
972
+ #[test]
973
+ fn prepare_chat_request_lowers_required_tool_choice() {
974
+ let request = ChatCompletionRequest {
975
+ tools: Some(vec![Tool {
976
+ tool_type: "function".to_string(),
977
+ function: Function {
978
+ name: "get_weather".to_string(),
979
+ description: Some("Get weather".to_string()),
980
+ parameters: json!({
981
+ "type": "object",
982
+ "properties": {"city": {"type": "string"}},
983
+ }),
984
+ strict: None,
985
+ },
986
+ }]),
987
+ tool_choice: Some(ToolChoice::Value(ToolChoiceValue::Required)),
988
+ ..base_request()
989
+ };
990
+
991
+ let prepared = prepare_chat_request(
992
+ request,
993
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
994
+ ResolvedRequestContext::default(),
995
+ )
996
+ .expect("request is valid");
997
+
998
+ assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Required);
999
+ }
1000
+
1001
+ #[test]
1002
+ fn prepare_chat_request_lowers_named_function_tool_choice() {
1003
+ let request = ChatCompletionRequest {
1004
+ tools: Some(vec![Tool {
1005
+ tool_type: "function".to_string(),
1006
+ function: Function {
1007
+ name: "get_weather".to_string(),
1008
+ description: Some("Get weather".to_string()),
1009
+ parameters: json!({
1010
+ "type": "object",
1011
+ "properties": {"city": {"type": "string"}},
1012
+ }),
1013
+ strict: None,
1014
+ },
1015
+ }]),
1016
+ tool_choice: Some(ToolChoice::Function {
1017
+ tool_type: "function".to_string(),
1018
+ function: crate::routes::openai::utils::types::FunctionChoice {
1019
+ name: "get_weather".to_string(),
1020
+ },
1021
+ }),
1022
+ ..base_request()
1023
+ };
1024
+
1025
+ let prepared = prepare_chat_request(
1026
+ request,
1027
+ &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
1028
+ ResolvedRequestContext::default(),
1029
+ )
1030
+ .expect("request is valid");
1031
+
1032
+ assert_eq!(
1033
+ prepared.chat_request.tool_choice,
1034
+ ChatToolChoice::Function {
1035
+ name: "get_weather".to_string(),
1036
+ }
1037
+ );
1038
+ }
1039
+
1040
#[test]
1041
fn prepare_chat_request_lowers_logprobs_fields() {
1042
let request = ChatCompletionRequest {
rust/src/server/src/routes/openai/chat_completions/validate.rs
+2
-61
@@ -1,6 +1,6 @@
1
use super::types::ChatCompletionRequest;
2
use crate::error::{ApiError, bail_invalid_request};
3
-use crate::routes::openai::utils::types::{ChatMessage, Tool, ToolChoice, ToolChoiceValue};
3
+use crate::routes::openai::utils::types::{ChatMessage, Tool};
4
5
/// Enforce the minimal compatibility contract for the Rust OpenAI server.
6
pub(super) fn validate_request_compat(
@@ -58,30 +58,6 @@ pub(super) fn validate_request_compat(
58
}
59
}
60
61
- if let Some(tool_choice) = &request.tool_choice {
62
- match tool_choice {
63
- ToolChoice::Value(ToolChoiceValue::Auto | ToolChoiceValue::None) => {}
64
- ToolChoice::Value(ToolChoiceValue::Required) => {
65
- bail_invalid_request!(
66
- param = "tool_choice",
67
- "tool_choice=required is not supported yet."
68
- );
69
- }
70
- ToolChoice::Function { .. } => {
71
- bail_invalid_request!(
72
- param = "tool_choice",
73
- "Named function tool_choice is not supported yet."
74
- );
75
- }
76
- ToolChoice::AllowedTools { .. } => {
77
- bail_invalid_request!(
78
- param = "tool_choice",
79
- "allowed_tools tool_choice is not supported yet."
80
- );
81
- }
82
- }
83
- }
84
-
61
if request.use_beam_search {
62
bail_invalid_request!(
63
param = "use_beam_search",
@@ -159,8 +135,7 @@ mod tests {
135
use crate::routes::openai::chat_completions::types::ChatCompletionRequest;
136
use crate::routes::openai::utils::structured_outputs::ResponseFormat;
137
use crate::routes::openai::utils::types::{
162
- ChatMessage, Function, FunctionChoice, MessageContent, StringOrArray, Tool, ToolChoice,
163
- ToolChoiceValue, ToolReference,
138
+ ChatMessage, Function, MessageContent, StringOrArray, Tool, ToolChoice, ToolChoiceValue,
139
};
140
141
fn served(names: &[&str]) -> Vec<String> {
@@ -357,38 +332,4 @@ mod tests {
332
validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"]))
333
.expect("tool_choice=none is ok");
334
}
360
-
361
- #[test]
362
- fn validate_request_compat_rejects_required_and_named_tool_choices() {
363
- let required = ChatCompletionRequest {
364
- tool_choice: Some(ToolChoice::Value(ToolChoiceValue::Required)),
365
- ..base_request()
366
- };
367
- assert!(validate_request_compat(&required, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err());
368
-
369
- let named = ChatCompletionRequest {
370
- tool_choice: Some(ToolChoice::Function {
371
- tool_type: "function".to_string(),
372
- function: FunctionChoice {
373
- name: "tool".to_string(),
374
- },
375
- }),
376
- ..base_request()
377
- };
378
- assert!(validate_request_compat(&named, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err());
379
-
380
- let allowed_tools = ChatCompletionRequest {
381
- tool_choice: Some(ToolChoice::AllowedTools {
382
- tool_type: "allowed_tools".to_string(),
383
- mode: "auto".to_string(),
384
- tools: vec![ToolReference::Function {
385
- name: "tool".to_string(),
386
- }],
387
- }),
388
- ..base_request()
389
- };
390
- assert!(
391
- validate_request_compat(&allowed_tools, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err()
392
- );
393
- }
335
}
rust/src/tool-parser/Cargo.toml
+1
@@ -14,6 +14,7 @@ serde_json.workspace = true
14
thiserror.workspace = true
15
thiserror-ext.workspace = true
16
winnow.workspace = true
17
+xgrammar-structural-tag.workspace = true
18
19
[dev-dependencies]
20
criterion.workspace = true
rust/src/tool-parser/src/deepseek_dsml/deepseek_v32.rs
+5
-1
@@ -1,5 +1,5 @@
1
use super::{DeepSeekDsmlToolParser, DsmlTokens};
2
-use crate::{Result, Tool, ToolParser, ToolParserOutput};
2
+use crate::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput};
3
4
/// Tool parser for DeepSeek V3.2 models.
5
///
@@ -44,6 +44,10 @@ impl ToolParser for DeepSeekV32ToolParser {
44
true
45
}
46
47
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
48
+ Some(StructuralTagModel::DeepSeekV32)
49
+ }
50
+
51
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
52
self.0.parse_into(chunk, output)
53
}
rust/src/tool-parser/src/deepseek_dsml/deepseek_v4.rs
+16
-2
@@ -1,5 +1,5 @@
1
use super::{DeepSeekDsmlToolParser, DsmlTokens};
2
-use crate::{Result, Tool, ToolParser, ToolParserOutput};
2
+use crate::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput};
3
4
/// Tool parser for DeepSeek V4 models.
5
///
@@ -47,6 +47,10 @@ impl ToolParser for DeepSeekV4ToolParser {
47
true
48
}
49
50
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
51
+ Some(StructuralTagModel::DeepSeekV4)
52
+ }
53
+
54
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
55
self.0.parse_into(chunk, output)
56
}
@@ -65,8 +69,8 @@ mod tests {
69
use serde_json::{Value, json};
70
71
use super::DeepSeekV4ToolParser;
68
- use crate::ToolParserTestExt as _;
72
use crate::test_utils::{collect_stream, test_tools};
73
+ use crate::{StructuralTagModel, ToolParser, ToolParserTestExt as _};
74
75
fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String {
76
let params = params
@@ -83,6 +87,16 @@ mod tests {
87
)
88
}
89
90
+ #[test]
91
+ fn deepseek_v4_exposes_structural_tag_model() {
92
+ let parser = DeepSeekV4ToolParser::new(&test_tools());
93
+
94
+ assert_eq!(
95
+ parser.structural_tag_model(),
96
+ Some(StructuralTagModel::DeepSeekV4)
97
+ );
98
+ }
99
+
100
#[test]
101
fn deepseek_v4_parse_complete_reuses_dsml_parser_with_tool_calls_token() {
102
let mut parser = DeepSeekV4ToolParser::new(&test_tools());
rust/src/tool-parser/src/deepseek_json/deepseek_v3.rs
+5
-1
@@ -1,5 +1,5 @@
1
use super::{DeepSeekJsonFormat, DeepSeekJsonToolParser};
2
-use crate::{Result, Tool, ToolParser, ToolParserOutput};
2
+use crate::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput};
3
4
/// Tool parser for DeepSeek V3 JSON-fenced tool calls.
5
///
@@ -32,6 +32,10 @@ impl ToolParser for DeepSeekV3ToolParser {
32
Ok(Box::new(Self::new(tools)))
33
}
34
35
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
36
+ Some(StructuralTagModel::DeepSeekR1)
37
+ }
38
+
39
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
40
self.0.parse_into(chunk, output)
41
}
rust/src/tool-parser/src/deepseek_json/deepseek_v31.rs
+5
-1
@@ -1,5 +1,5 @@
1
use super::{DeepSeekJsonFormat, DeepSeekJsonToolParser};
2
-use crate::{Result, Tool, ToolParser, ToolParserOutput};
2
+use crate::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput};
3
4
/// Tool parser for DeepSeek V3.1 raw JSON tool calls.
5
///
@@ -28,6 +28,10 @@ impl ToolParser for DeepSeekV31ToolParser {
28
Ok(Box::new(Self::new(tools)))
29
}
30
31
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
32
+ Some(StructuralTagModel::DeepSeekV31)
33
+ }
34
+
35
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
36
self.0.parse_into(chunk, output)
37
}
rust/src/tool-parser/src/glm_xml/glm47_moe.rs
+5
-1
@@ -1,5 +1,5 @@
1
use super::{GlmXmlToolParser, Separator};
2
-use crate::{Result, Tool, ToolParser, ToolParserOutput};
2
+use crate::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput};
3
4
/// Tool parser for GLM-4.7 MoE XML-style tool calls.
5
///
@@ -22,6 +22,10 @@ impl ToolParser for Glm47MoeToolParser {
22
Ok(Box::new(Self::new(tools)))
23
}
24
25
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
26
+ Some(StructuralTagModel::Glm47)
27
+ }
28
+
29
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
30
self.0.parse_into(chunk, output)
31
}
rust/src/tool-parser/src/hy_v3.rs
+5
-1
@@ -7,7 +7,7 @@ use winnow::token::{literal, rest, take_until};
7
use super::parameters::ToolSchemas;
8
use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
9
use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10
-use crate::Tool;
10
+use crate::{StructuralTagModel, Tool};
11
12
const TOOL_CALLS_START: &str = "<tool_calls>";
13
const TOOL_CALLS_END: &str = "</tool_calls>";
@@ -113,6 +113,10 @@ impl ToolParser for HyV3ToolParser {
113
Ok(Box::new(Self::new(tools)))
114
}
115
116
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
117
+ Some(StructuralTagModel::HyV3)
118
+ }
119
+
120
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
121
self.buffer.push_str(chunk);
122
rust/src/tool-parser/src/json/hermes.rs
+5
-1
@@ -1,5 +1,5 @@
1
use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace};
2
-use crate::{Result, Tool, ToolParser, ToolParserOutput};
2
+use crate::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput};
3
4
const HERMES_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
5
parser_name: "Hermes",
@@ -45,6 +45,10 @@ impl ToolParser for HermesToolParser {
45
Ok(Box::new(Self::new(tools)))
46
}
47
48
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
49
+ Some(StructuralTagModel::Hermes)
50
+ }
51
+
52
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
53
self.inner.parse_into(chunk, output)
54
}
rust/src/tool-parser/src/json/llama.rs
+5
-1
@@ -9,7 +9,7 @@ use super::{
9
argument_delta_event, tool_call_header_event,
10
};
11
use crate::utils::{JsonObjectScanState, parse_buffered_event};
12
-use crate::{Result, Tool, ToolCallDelta, ToolParser, ToolParserOutput};
12
+use crate::{Result, StructuralTagModel, Tool, ToolCallDelta, ToolParser, ToolParserOutput};
13
14
#[derive(Debug, Clone, PartialEq, Eq)]
15
enum LlamaJsonMode {
@@ -133,6 +133,10 @@ impl ToolParser for Llama3JsonToolParser {
133
Ok(Box::new(Self::new(tools)))
134
}
135
136
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
137
+ Some(StructuralTagModel::Llama)
138
+ }
139
+
140
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
141
self.buffer.push_str(chunk);
142
rust/src/tool-parser/src/json/qwen.rs
+5
-1
@@ -1,5 +1,5 @@
1
use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace};
2
-use crate::{Result, Tool, ToolParser, ToolParserOutput};
2
+use crate::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput};
3
4
const QWEN_XML_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
5
parser_name: "Qwen XML",
@@ -47,6 +47,10 @@ impl ToolParser for Qwen3XmlToolParser {
47
Ok(Box::new(Self::new(tools)))
48
}
49
50
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
51
+ Some(StructuralTagModel::Qwen3)
52
+ }
53
+
54
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
55
self.inner.parse_into(chunk, output)
56
}
rust/src/tool-parser/src/kimi_k2.rs
+5
-1
@@ -8,7 +8,7 @@ use winnow::token::{literal, rest, take_until, take_while};
8
9
use super::utils::{JsonObjectScanState, parse_buffered_event, safe_text_len, take_json_object};
10
use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
11
-use crate::Tool;
11
+use crate::{StructuralTagModel, Tool};
12
13
const TOOL_CALLS_START: &str = "<|tool_calls_section_begin|>";
14
const TOOL_CALLS_END: &str = "<|tool_calls_section_end|>";
@@ -147,6 +147,10 @@ impl ToolParser for KimiK2ToolParser {
147
true
148
}
149
150
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
151
+ Some(StructuralTagModel::Kimi)
152
+ }
153
+
154
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
155
self.call_ids.get(&tool_index).map(String::as_str)
156
}
rust/src/tool-parser/src/lib.rs
+6
@@ -35,6 +35,7 @@ pub use minimax_m3::MinimaxM3ToolParser;
35
pub use qwen_coder::Qwen3CoderToolParser;
36
use serde::{Deserialize, Serialize};
37
use serde_json::Value;
38
+pub use xgrammar_structural_tag::Model as StructuralTagModel;
39
40
/// One function-style tool made available to the model.
41
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -123,6 +124,11 @@ pub trait ToolParser: Send {
124
false
125
}
126
127
+ /// Return the xgrammar structural-tag model used for strict tool calling.
128
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
129
+ None
130
+ }
131
+
132
/// Return the parser-provided ID for a tool call by index, if the model
133
/// emitted one.
134
fn tool_call_id(&self, _tool_index: usize) -> Option<&str> {
rust/src/tool-parser/src/minimax_m2.rs
+5
-1
@@ -7,7 +7,7 @@ use winnow::token::{literal, rest, take_until};
7
use super::parameters::ToolSchemas;
8
use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
9
use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10
-use crate::Tool;
10
+use crate::{StructuralTagModel, Tool};
11
12
const TOOL_CALL_START: &str = "<minimax:tool_call>";
13
const TOOL_CALL_END: &str = "</minimax:tool_call>";
@@ -112,6 +112,10 @@ impl ToolParser for MinimaxM2ToolParser {
112
Ok(Box::new(Self::new(tools)))
113
}
114
115
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
116
+ Some(StructuralTagModel::Minimax)
117
+ }
118
+
119
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
120
self.buffer.push_str(chunk);
121
rust/src/tool-parser/src/qwen_coder.rs
+16
-2
@@ -6,7 +6,7 @@ use winnow::token::{literal, take_until};
6
7
use super::parameters::ToolSchemas;
8
use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
9
-use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
9
+use super::{Result, StructuralTagModel, ToolCallDelta, ToolParser, ToolParserOutput};
10
use crate::Tool;
11
12
const TOOL_CALL_START: &str = "<tool_call>";
@@ -113,6 +113,10 @@ impl ToolParser for Qwen3CoderToolParser {
113
Ok(Box::new(Self::new(tools)))
114
}
115
116
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
117
+ Some(StructuralTagModel::Qwen3Coder)
118
+ }
119
+
120
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
121
self.buffer.push_str(chunk);
122
@@ -236,7 +240,7 @@ mod tests {
240
use serde_json::{Value, json};
241
use thiserror_ext::AsReport;
242
239
- use super::{Qwen3CoderToolParser, ToolParser};
243
+ use super::{Qwen3CoderToolParser, StructuralTagModel, ToolParser};
244
use crate::test_utils::{collect_stream, split_by_chars, test_tools};
245
use crate::{ToolParserOutput, ToolParserTestExt as _};
246
@@ -249,6 +253,16 @@ mod tests {
253
format!("<tool_call>\n<function={function_name}>\n{params}\n</function>\n</tool_call>")
254
}
255
256
+ #[test]
257
+ fn qwen_coder_exposes_structural_tag_model() {
258
+ let parser = Qwen3CoderToolParser::new(&test_tools());
259
+
260
+ assert_eq!(
261
+ parser.structural_tag_model(),
262
+ Some(StructuralTagModel::Qwen3Coder)
263
+ );
264
+ }
265
+
266
#[test]
267
fn qwen_coder_parse_complete_without_tool_call_keeps_text() {
268
let mut parser = Qwen3CoderToolParser::new(&test_tools());