[Frontend] Add `--tool-strict-level` for server-side control for structural tag activation (#56268)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: lazymio <mio@lazym.io> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
lazymio committed
Sep 18, 2026 at 12:32 UTC
dee37d89115db4c94a820a79a78a7828e141c910
35 files changed
+742
-61
docs/features/tool_calling.md
+30
-10
@@ -109,18 +109,22 @@ vLLM supports the `tool_choice='none'` option in the chat completion API. When t
109
110
## Constrained Decoding Behavior
111
112
-Whether vLLM enforces the tool parameter schema during generation depends on the `tool_choice` mode and the per-tool `strict` field:
112
+Structural-tag parsers resolve call obligation, grammar activation, and argument-schema enforcement separately. With structural-tag enforcement enabled:
113
114
-| `tool_choice` value | Schema-constrained decoding | Behavior |
114
+| `tool_choice` value | Call obligation | Structural-tag activation |
115
| --- | --- | --- |
116
-| Named function | Yes (via structured outputs backend) | Arguments are guaranteed to be valid JSON conforming to the function's parameter schema. |
117
-| `"required"` | Yes (via structured outputs backend) | Same as named function. The model must produce at least one tool call. |
118
-| `"auto"` | Only when `strict: true` is set on at least one tool | Structural-tag parsers constrain tool-call arguments when a tool opts in with `strict: true`. Without it, the model generates freely and tool calls are extracted from raw text. |
119
-| `"none"` | N/A | No tool calls are produced. |
116
+| Named function | Call the selected function | Always |
117
+| `"required"` | Produce at least one tool call | Always |
118
+| `"auto"` | Tool calls are optional | When at least one tool sets `strict: true`, or `--tool-strict-level` is `function` or `parameter` |
119
+| `"none"` | Tool calling is disabled | Disabled |
120
+
121
+When a structural tag applies, each tool's declared parameter schema is enforced only when that tool sets `strict: true` or the server uses `--tool-strict-level parameter`. Tools with omitted or false `strict` receive broad argument-syntax constraints at levels `auto` and `function`, including for required and named calls.
122
+
123
+For parsers using schema-derived JSON constraints, required and named calls continue to enforce the declared parameter schemas.
124
125
### Strict Mode
126
123
-For `tool_choice="required"` or named function calling, structural-tag constraints are always applied regardless of the `strict` field. For `tool_choice="auto"`, setting `strict: true` on at least one tool opts in to structural-tag constraints; without it, the model generates freely and tool calls are extracted from raw text. The `strict` field is supported across all three API surfaces: Chat Completion, Responses, and Anthropic Messages.
127
+Structural-tag constraints have two layers. The tool-call *envelope* (markup and function name) is constrained whenever a structural tag applies: always for `tool_choice="required"` and named function calling, and for `tool_choice="auto"` once at least one tool sets `strict: true` or the server raises the floor. The *argument schema* of an individual tool is pinned only when that tool sets `strict: true` (or under `--tool-strict-level parameter`); a tool that omits `strict` keeps its arguments unconstrained, even when another tool in the same request is strict. The `strict` field is supported across all three API surfaces: Chat Completion, Responses, and Anthropic Messages.
128
129
For best compatibility with strict schema enforcement, define tool parameter schemas in the OpenAI strict-schema style:
130
@@ -134,6 +138,22 @@ vLLM also provides a global toggle via the `VLLM_ENFORCE_STRICT_TOOL_CALLING` en
138
VLLM_ENFORCE_STRICT_TOOL_CALLING=false vllm serve ...
139
```
140
141
+### Server-Side Strictness Floor
142
+
143
+Most OpenAI-compatible clients and agent frameworks never set `strict` on their tools, so with `tool_choice="auto"` the model generates tool calls without any grammar and malformed markup can leak into the response. The `--tool-strict-level` option lets the server operator raise the floor for every request that carries tools, independently of what the client declares:
144
+
145
+| Value | Behavior |
146
+| --- | --- |
147
+| `auto` (default) | Follow the request's tool choice and per-tool strictness. Required/named choices activate structural tags; `tool_choice="auto"` activates them when at least one tool sets `strict: true`. |
148
+| `function` | Constrain the tool-call envelope (markup and the function name) for every request with tools, leaving argument contents free unless the client marked the tool `strict: true`. |
149
+| `parameter` | Additionally pin argument schemas for every tool, as if every tool had `strict: true`. |
150
+
151
+```bash
152
+vllm serve ... --tool-strict-level function
153
+```
154
+
155
+The floor never relaxes a constraint the request would already receive: tools the client marked `strict: true` keep their schemas at every level. With `tool_choice="auto"`, the grammar does not force a tool call; a plain text response stays valid. `VLLM_ENFORCE_STRICT_TOOL_CALLING=false` disables structural tags entirely and takes precedence over this option.
156
+
157
## Automatic Function Calling
158
159
To enable this feature, you should set the following flags:
@@ -152,7 +172,7 @@ from HuggingFace; and you can find an example of this in a `tokenizer_config.jso
172
If your favorite tool-calling model is not supported, please feel free to contribute a parser & tool use chat template!
173
174
!!! note
155
- With `tool_choice="auto"`, schema-level constraint requires both `VLLM_ENFORCE_STRICT_TOOL_CALLING=true` (the default) and at least one tool with `strict: true`. When these conditions are met and the selected parser supports structural tags, vLLM constrains tool-call arguments. Otherwise, vLLM extracts tool calls from raw text, so arguments may occasionally be malformed or violate the function's parameter schema.
175
+ With `tool_choice="auto"`, structural-tag constraints require both `VLLM_ENFORCE_STRICT_TOOL_CALLING=true` (the default) and at least one tool with `strict: true`, or a server-side floor set via `--tool-strict-level`. When these conditions are met and the selected parser supports structural tags, vLLM constrains the tool-call envelope and pins the argument schema of each tool that sets `strict: true` (or of every tool under `--tool-strict-level parameter`). Otherwise, vLLM extracts tool calls from raw text, so arguments may occasionally be malformed or violate the function's parameter schema.
176
177
### Hermes Models (`hermes`)
178
@@ -162,8 +182,8 @@ All Nous Research Hermes-series models newer than Hermes 2 Pro should be support
182
* `NousResearch/Hermes-2-Theta-*`
183
* `NousResearch/Hermes-3-*`
184
165
-_Note that the Hermes 2 **Theta** models are known to have degraded tool call quality and capabilities due to the merge
166
-step in their creation_.
185
+*Note that the Hermes 2 **Theta** models are known to have degraded tool call quality and capabilities due to the merge
186
+step in their creation*.
187
188
Flags: `--tool-call-parser hermes`
189
rust/src/chat/src/backend/hf.rs
+2
@@ -134,6 +134,7 @@ impl ChatBackend for HfChatBackend {
134
self.tokenizer.clone(),
135
options.tool_call_parser,
136
options.reasoning_parser,
137
+ options.tool_strict_level,
138
)?))
139
}
140
}
@@ -343,6 +344,7 @@ mod tests {
344
let error = match backend.new_chat_output_processor(
345
&mut request,
346
NewChatOutputProcessorOptions {
347
+ tool_strict_level: crate::ToolStrictLevel::Auto,
348
tool_call_parser: &ParserSelection::Explicit("json".to_string()),
349
reasoning_parser: &ParserSelection::Auto,
350
},
rust/src/chat/src/backend/mod.rs
+2
-1
@@ -13,7 +13,7 @@ use crate::multimodal::{MmLimitPerPrompt, MultimodalModelInfo};
13
use crate::output::DynChatOutputProcessor;
14
use crate::renderer::DynChatRenderer;
15
use crate::request::ChatRequest;
16
-use crate::{ChatTemplateContentFormatOption, ParserSelection, RendererSelection};
16
+use crate::{ChatTemplateContentFormatOption, ParserSelection, RendererSelection, ToolStrictLevel};
17
18
pub mod hf;
19
@@ -21,6 +21,7 @@ pub mod hf;
21
pub struct NewChatOutputProcessorOptions<'a> {
22
pub tool_call_parser: &'a ParserSelection,
23
pub reasoning_parser: &'a ParserSelection,
24
+ pub tool_strict_level: ToolStrictLevel,
25
}
26
27
/// Minimal prompt-processing backend needed by `vllm-chat`.
rust/src/chat/src/lib.rs
+18
-1
@@ -30,7 +30,7 @@ pub use parser::reasoning::{
30
ReasoningDelta, ReasoningError, ReasoningParser, ReasoningParserFactory,
31
};
32
pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory};
33
-pub use parser::{ParserSelection, validate_parser_overrides};
33
+pub use parser::{ParserSelection, ToolStrictLevel, validate_parser_overrides};
34
pub use reasoning::EffortValue;
35
pub use renderer::hf::ChatTemplateContentFormatOption;
36
pub use renderer::{
@@ -74,6 +74,8 @@ pub struct ChatRequestProcessor {
74
tool_call_parser: ParserSelection,
75
/// Reasoning parser selection used when preparing generation requests.
76
reasoning_parser: ParserSelection,
77
+ /// Server-side floor for tool-call structural tags.
78
+ tool_strict_level: ToolStrictLevel,
79
}
80
81
impl ChatRequestProcessor {
@@ -85,6 +87,7 @@ impl ChatRequestProcessor {
87
model_dtype: Some(model_dtype),
88
tool_call_parser: ParserSelection::Auto,
89
reasoning_parser: ParserSelection::Auto,
90
+ tool_strict_level: ToolStrictLevel::Auto,
91
}
92
}
93
@@ -95,6 +98,7 @@ impl ChatRequestProcessor {
98
model_dtype: None,
99
tool_call_parser: ParserSelection::Auto,
100
reasoning_parser: ParserSelection::Auto,
101
+ tool_strict_level: ToolStrictLevel::Auto,
102
}
103
}
104
@@ -109,6 +113,12 @@ impl ChatRequestProcessor {
113
self
114
}
115
116
+ /// Configure the server-side floor for tool-call structural tags.
117
+ pub fn with_tool_strict_level(mut self, tool_strict_level: ToolStrictLevel) -> Self {
118
+ self.tool_strict_level = tool_strict_level;
119
+ self
120
+ }
121
+
122
async fn finalize_rendered_prompt(
123
&self,
124
request: &ChatRequest,
@@ -207,6 +217,7 @@ impl ChatRequestProcessor {
217
NewChatOutputProcessorOptions {
218
tool_call_parser: &self.tool_call_parser,
219
reasoning_parser: &self.reasoning_parser,
220
+ tool_strict_level: self.tool_strict_level,
221
},
222
)?;
223
let text_request = self.prepare_text_request(request).await?;
@@ -255,6 +266,12 @@ impl ChatLlm {
266
self
267
}
268
269
+ /// Set the server-side floor for tool-call structural tags.
270
+ pub fn with_tool_strict_level(mut self, tool_strict_level: ToolStrictLevel) -> Self {
271
+ self.processor.tool_strict_level = tool_strict_level;
272
+ self
273
+ }
274
+
275
/// Override the effective model dtype used for multimodal tensor encoding.
276
pub fn with_model_dtype(mut self, model_dtype: ModelDtype) -> Self {
277
self.processor.model_dtype = Some(model_dtype);
rust/src/chat/src/output/default/mod.rs
+12
-3
@@ -18,10 +18,10 @@ use self::unified::unified_event_stream;
18
use super::structured::structured_chat_event_stream;
19
use crate::error::Result;
20
use crate::output::{ChatOutputProcessor, DynChatEventStream, DynDecodedTextEventStream};
21
-use crate::parser::ParserSelection;
21
use crate::parser::reasoning::{ReasoningParser, ReasoningParserFactory};
22
use crate::parser::tool::{ToolParser, ToolParserFactory};
23
use crate::parser::unified::UnifiedParserFactory;
24
+use crate::parser::{ParserSelection, ToolStrictLevel};
25
use crate::request::{ChatRequest, ChatTool};
26
use crate::{Error, Result as ChatResult};
27
@@ -49,6 +49,7 @@ impl DefaultChatOutputProcessor {
49
tokenizer: DynTokenizer,
50
tool_call_parser: &ParserSelection,
51
reasoning_parser: &ParserSelection,
52
+ tool_strict_level: ToolStrictLevel,
53
) -> ChatResult<Self> {
54
let parser = if let Some(parser) = Self::resolve_optional_unified_parser(
55
request.tools(),
@@ -73,7 +74,11 @@ impl DefaultChatOutputProcessor {
74
Box::new(CombinedParser::new(reasoning_parser, tool_parser)) as Box<dyn UnifiedParser>
75
};
76
76
- apply_structural_tag_constraint(request, parser.structural_tag_builder())?;
77
+ apply_structural_tag_constraint(
78
+ request,
79
+ parser.structural_tag_builder(),
80
+ tool_strict_level,
81
+ )?;
82
83
if parser.preserve_special_tokens() {
84
request.decode_options.skip_special_tokens = false;
@@ -191,7 +196,7 @@ mod tests {
196
use vllm_tokenizer::test_utils::TestTokenizer;
197
198
use super::DefaultChatOutputProcessor;
194
- use crate::parser::ParserSelection;
199
+ use crate::parser::{ParserSelection, ToolStrictLevel};
200
use crate::request::ChatRequest;
201
202
fn tokenizer() -> Arc<TestTokenizer> {
@@ -213,6 +218,7 @@ mod tests {
218
tokenizer(),
219
&selection,
220
&selection,
221
+ ToolStrictLevel::Auto,
222
)
223
.unwrap();
224
}
@@ -227,6 +233,7 @@ mod tests {
233
tokenizer(),
234
&ParserSelection::Auto,
235
&ParserSelection::Auto,
236
+ ToolStrictLevel::Auto,
237
)
238
.unwrap();
239
}
@@ -244,6 +251,7 @@ mod tests {
251
tokenizer(),
252
tool,
253
reasoning,
254
+ ToolStrictLevel::Auto,
255
)
256
.unwrap();
257
}
@@ -258,6 +266,7 @@ mod tests {
266
tokenizer(),
267
&ParserSelection::Auto,
268
&ParserSelection::Explicit("gemma4".to_string()),
269
+ ToolStrictLevel::Auto,
270
) {
271
Ok(_) => panic!("expected mixed Gemma4 parser selection to fail"),
272
Err(error) => error,
rust/src/chat/src/output/default/structural_tag.rs
+191
-29
@@ -14,19 +14,21 @@ use xgrammar_structural_tag::{
14
build_structural_tag,
15
};
16
17
+use crate::parser::ToolStrictLevel;
18
use crate::request::{ChatRequest, ChatToolChoice};
19
use crate::{Error, Result as ChatResult};
20
21
/// Apply structural tag constraints to the request based on the tool parser's structural tag
21
-/// support and the request's tool choice.
22
+/// support, the request's tool choice, and the server-side strictness floor.
23
pub(super) fn apply_structural_tag_constraint(
24
request: &mut ChatRequest,
25
builder: Option<&dyn StructuralTagBuilder>,
26
+ strict_level: ToolStrictLevel,
27
) -> ChatResult<()> {
28
let Some(builder) = builder else {
29
return Ok(());
30
};
29
- let Some(tool_choice) = structural_tag_tool_choice(request) else {
31
+ let Some(tool_choice) = structural_tag_tool_choice(request, strict_level) else {
32
return Ok(());
33
};
34
@@ -34,11 +36,14 @@ pub(super) fn apply_structural_tag_constraint(
36
.tools()
37
.iter()
38
.map(|tool| {
39
+ // A tool without an explicit `strict` is non-strict: its call envelope is
40
+ // constrained, but its arguments stay free unless the server level is `parameter`.
41
+ let strict = strict_level >= ToolStrictLevel::Parameter || tool.strict == Some(true);
42
ToolParam::Function(FunctionToolParam::new(FunctionDefinition {
43
name: tool.name.clone(),
44
description: tool.description.clone(),
45
parameters: Some(tool.parameters.clone()),
41
- strict: tool.strict,
46
+ strict: Some(strict),
47
}))
48
})
49
.collect::<Vec<_>>();
@@ -66,17 +71,21 @@ pub(super) fn apply_structural_tag_constraint(
71
/// Resolve the tool choice used for [`xgrammar_structural_tag`] based on the request.
72
///
73
/// Returns `None` if no structural tag constraints should be applied.
69
-fn structural_tag_tool_choice(request: &ChatRequest) -> Option<StructuralTagToolChoice> {
74
+fn structural_tag_tool_choice(
75
+ request: &ChatRequest,
76
+ strict_level: ToolStrictLevel,
77
+) -> Option<StructuralTagToolChoice> {
78
if request.tools().is_empty() {
79
return None;
80
}
81
82
match request.tool_choice() {
75
- // For `Auto`, only apply the structural tag if there's at least one strict tool.
76
- ChatToolChoice::Auto if request.tools().iter().any(|tool| tool.strict == Some(true)) => {
77
- Some(StructuralTagToolChoice::auto())
78
- }
79
- ChatToolChoice::Auto | ChatToolChoice::None => None,
83
+ ChatToolChoice::None => None,
84
+ // For `Auto`, apply the structural tag only when the server raises the floor or at
85
+ // least one tool opts in with `strict: true`.
86
+ ChatToolChoice::Auto => (strict_level >= ToolStrictLevel::Function
87
+ || request.tools().iter().any(|tool| tool.strict == Some(true)))
88
+ .then(StructuralTagToolChoice::auto),
89
90
ChatToolChoice::Required => Some(StructuralTagToolChoice::required()),
91
ChatToolChoice::Function { name } => Some(StructuralTagToolChoice::function(name.clone())),
@@ -148,8 +157,12 @@ mod tests {
157
let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
158
let parser = qwen3_coder_parser(request.tools());
159
151
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
152
- .expect("structural tag should build");
160
+ apply_structural_tag_constraint(
161
+ &mut request,
162
+ parser.structural_tag_builder(),
163
+ ToolStrictLevel::Auto,
164
+ )
165
+ .expect("structural tag should build");
166
167
let tag = structural_tag_value(&request);
168
assert_eq!(tag["type"], "structural_tag");
@@ -172,8 +185,12 @@ mod tests {
185
};
186
let parser = qwen3_coder_parser(request.tools());
187
175
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
176
- .expect("structural tag should build");
188
+ apply_structural_tag_constraint(
189
+ &mut request,
190
+ parser.structural_tag_builder(),
191
+ ToolStrictLevel::Auto,
192
+ )
193
+ .expect("structural tag should build");
194
195
let tag = structural_tag_value(&request);
196
assert!(tag.to_string().contains("lookup"));
@@ -199,8 +216,12 @@ mod tests {
216
};
217
let parser = qwen3_coder_parser(request.tools());
218
202
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
203
- .expect("structural tag should build");
219
+ apply_structural_tag_constraint(
220
+ &mut request,
221
+ parser.structural_tag_builder(),
222
+ ToolStrictLevel::Auto,
223
+ )
224
+ .expect("structural tag should build");
225
226
let tag = structural_tag_value(&request).to_string();
227
assert!(tag.contains("search"));
@@ -212,8 +233,12 @@ mod tests {
233
let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", None)]);
234
let parser = qwen3_coder_parser(request.tools());
235
215
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
216
- .expect("structural tag decision should succeed");
236
+ apply_structural_tag_constraint(
237
+ &mut request,
238
+ parser.structural_tag_builder(),
239
+ ToolStrictLevel::Auto,
240
+ )
241
+ .expect("structural tag decision should succeed");
242
243
assert!(request.sampling_params.structured_outputs.is_none());
244
}
@@ -227,8 +252,12 @@ mod tests {
252
});
253
let parser = qwen3_coder_parser(request.tools());
254
230
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
231
- .expect("structural tag should build");
255
+ apply_structural_tag_constraint(
256
+ &mut request,
257
+ parser.structural_tag_builder(),
258
+ ToolStrictLevel::Auto,
259
+ )
260
+ .expect("structural tag should build");
261
262
let params = structured_outputs(&request);
263
assert!(params.constraint.is_structural_tag());
@@ -242,8 +271,12 @@ mod tests {
271
let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
272
let parser = qwen3_coder_parser(request.tools());
273
245
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
246
- .expect("structural tag should build");
274
+ apply_structural_tag_constraint(
275
+ &mut request,
276
+ parser.structural_tag_builder(),
277
+ ToolStrictLevel::Auto,
278
+ )
279
+ .expect("structural tag should build");
280
281
let tag = structural_tag_value(&request);
282
assert_eq!(tag["type"], "structural_tag");
@@ -259,8 +292,12 @@ mod tests {
292
});
293
let parser = qwen3_coder_parser(request.tools());
294
262
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
263
- .expect("structural tag should build");
295
+ apply_structural_tag_constraint(
296
+ &mut request,
297
+ parser.structural_tag_builder(),
298
+ ToolStrictLevel::Auto,
299
+ )
300
+ .expect("structural tag should build");
301
302
let params = structured_outputs(&request);
303
assert!(params.constraint.is_structural_tag());
@@ -279,8 +316,12 @@ mod tests {
316
);
317
let parser = qwen3_coder_parser(request.tools());
318
282
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
283
- .expect("structural tag should build");
319
+ apply_structural_tag_constraint(
320
+ &mut request,
321
+ parser.structural_tag_builder(),
322
+ ToolStrictLevel::Auto,
323
+ )
324
+ .expect("structural tag should build");
325
326
let tag = structural_tag_value(&request).to_string();
327
assert!(tag.contains("lookup"));
@@ -292,8 +333,12 @@ mod tests {
333
let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
334
let parser = qwen3_coder_parser(request.tools());
335
295
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
296
- .expect("structural tag decision should succeed");
336
+ apply_structural_tag_constraint(
337
+ &mut request,
338
+ parser.structural_tag_builder(),
339
+ ToolStrictLevel::Auto,
340
+ )
341
+ .expect("structural tag decision should succeed");
342
343
assert!(request.sampling_params.structured_outputs.is_none());
344
}
@@ -307,10 +352,127 @@ mod tests {
352
});
353
let parser = qwen3_coder_parser(request.tools());
354
310
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder())
311
- .expect("structural tag decision should succeed");
355
+ apply_structural_tag_constraint(
356
+ &mut request,
357
+ parser.structural_tag_builder(),
358
+ ToolStrictLevel::Auto,
359
+ )
360
+ .expect("structural tag decision should succeed");
361
362
let params = structured_outputs(&request);
363
assert!(params.constraint.is_json_object());
364
}
365
+
366
+ fn build(tool_choice: ChatToolChoice, tools: Vec<Tool>, level: ToolStrictLevel) -> ChatRequest {
367
+ let mut request = request(tool_choice, tools);
368
+ let parser = qwen3_coder_parser(request.tools());
369
+
370
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_builder(), level)
371
+ .expect("structural tag decision should succeed");
372
+ request
373
+ }
374
+
375
+ fn tag_string(request: &ChatRequest) -> String {
376
+ structural_tag_value(request).to_string()
377
+ }
378
+
379
+ #[test]
380
+ fn function_level_constrains_auto_without_strict_tools() {
381
+ let request = build(
382
+ ChatToolChoice::Auto,
383
+ vec![chat_tool("search", None)],
384
+ ToolStrictLevel::Function,
385
+ );
386
+
387
+ let tag = structural_tag_value(&request);
388
+ assert_eq!(tag["type"], "structural_tag");
389
+ assert!(tag.to_string().contains("search"));
390
+ }
391
+
392
+ #[test]
393
+ fn absent_strict_is_non_strict() {
394
+ let unset = build(
395
+ ChatToolChoice::Required,
396
+ vec![chat_tool("search", None)],
397
+ ToolStrictLevel::Auto,
398
+ );
399
+ let non_strict = build(
400
+ ChatToolChoice::Required,
401
+ vec![chat_tool("search", Some(false))],
402
+ ToolStrictLevel::Auto,
403
+ );
404
+ let strict = build(
405
+ ChatToolChoice::Required,
406
+ vec![chat_tool("search", Some(true))],
407
+ ToolStrictLevel::Auto,
408
+ );
409
+
410
+ assert_eq!(tag_string(&unset), tag_string(&non_strict));
411
+ assert_ne!(tag_string(&unset), tag_string(&strict));
412
+ }
413
+
414
+ #[test]
415
+ fn strict_tool_does_not_pin_its_neighbour() {
416
+ let mixed = build(
417
+ ChatToolChoice::Auto,
418
+ vec![chat_tool("weather", Some(true)), chat_tool("search", None)],
419
+ ToolStrictLevel::Auto,
420
+ );
421
+ let explicit = build(
422
+ ChatToolChoice::Auto,
423
+ vec![
424
+ chat_tool("weather", Some(true)),
425
+ chat_tool("search", Some(false)),
426
+ ],
427
+ ToolStrictLevel::Auto,
428
+ );
429
+
430
+ assert_eq!(tag_string(&mixed), tag_string(&explicit));
431
+ }
432
+
433
+ #[test]
434
+ fn parameter_level_pins_argument_schemas() {
435
+ let pinned = build(
436
+ ChatToolChoice::Auto,
437
+ vec![chat_tool("search", None)],
438
+ ToolStrictLevel::Parameter,
439
+ );
440
+ let strict = build(
441
+ ChatToolChoice::Auto,
442
+ vec![chat_tool("search", Some(true))],
443
+ ToolStrictLevel::Auto,
444
+ );
445
+ let envelope_only = build(
446
+ ChatToolChoice::Auto,
447
+ vec![chat_tool("search", None)],
448
+ ToolStrictLevel::Function,
449
+ );
450
+
451
+ assert_eq!(tag_string(&pinned), tag_string(&strict));
452
+ assert_ne!(tag_string(&pinned), tag_string(&envelope_only));
453
+ }
454
+
455
+ #[test]
456
+ fn tool_strict_level_parses_case_insensitively() {
457
+ assert_eq!("AUTO".parse::<ToolStrictLevel>(), Ok(ToolStrictLevel::Auto));
458
+ assert_eq!(
459
+ "PARAMETER".parse::<ToolStrictLevel>(),
460
+ Ok(ToolStrictLevel::Parameter)
461
+ );
462
+ assert_eq!(
463
+ "function".parse::<ToolStrictLevel>(),
464
+ Ok(ToolStrictLevel::Function)
465
+ );
466
+ assert_eq!(ToolStrictLevel::default(), ToolStrictLevel::Auto);
467
+ assert_eq!(
468
+ serde_json::to_value(ToolStrictLevel::Auto).unwrap(),
469
+ json!("auto")
470
+ );
471
+ assert_eq!(
472
+ serde_json::from_value::<ToolStrictLevel>(json!("auto")).unwrap(),
473
+ ToolStrictLevel::Auto
474
+ );
475
+ assert!("off".parse::<ToolStrictLevel>().is_err());
476
+ assert!("strict".parse::<ToolStrictLevel>().is_err());
477
+ }
478
}
rust/src/chat/src/parser/mod.rs
+60
@@ -57,6 +57,66 @@ impl ParserSelection {
57
}
58
}
59
60
+/// Server-side floor for tool-call structural tags (`--tool-strict-level`).
61
+#[derive(
62
+ Debug,
63
+ Clone,
64
+ Copy,
65
+ PartialEq,
66
+ Eq,
67
+ PartialOrd,
68
+ Ord,
69
+ Default,
70
+ DeserializeFromStr,
71
+ SerializeDisplay,
72
+)]
73
+pub enum ToolStrictLevel {
74
+ /// Derive tool constraints from the request's tool choice and per-tool strictness.
75
+ #[default]
76
+ Auto,
77
+ /// Constrain the tool-call envelope for every request with tools.
78
+ Function,
79
+ /// Additionally pin argument schemas, as if every tool were `strict: true`.
80
+ Parameter,
81
+}
82
+
83
+impl ToolStrictLevel {
84
+ pub const AUTO_LITERAL: &str = "auto";
85
+ pub const FUNCTION_LITERAL: &str = "function";
86
+ pub const PARAMETER_LITERAL: &str = "parameter";
87
+}
88
+
89
+impl FromStr for ToolStrictLevel {
90
+ type Err = String;
91
+
92
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
93
+ if value.eq_ignore_ascii_case(Self::AUTO_LITERAL) {
94
+ Ok(Self::Auto)
95
+ } else if value.eq_ignore_ascii_case(Self::FUNCTION_LITERAL) {
96
+ Ok(Self::Function)
97
+ } else if value.eq_ignore_ascii_case(Self::PARAMETER_LITERAL) {
98
+ Ok(Self::Parameter)
99
+ } else {
100
+ Err(format!(
101
+ "unknown tool strict level {value:?}; expected one of {}, {}, {}",
102
+ Self::AUTO_LITERAL,
103
+ Self::FUNCTION_LITERAL,
104
+ Self::PARAMETER_LITERAL
105
+ ))
106
+ }
107
+ }
108
+}
109
+
110
+impl fmt::Display for ToolStrictLevel {
111
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112
+ f.write_str(match self {
113
+ Self::Auto => Self::AUTO_LITERAL,
114
+ Self::Function => Self::FUNCTION_LITERAL,
115
+ Self::Parameter => Self::PARAMETER_LITERAL,
116
+ })
117
+ }
118
+}
119
+
120
/// Validate explicit parser override names without starting request processing.
121
pub fn validate_parser_overrides(
122
tool_call_parser: &ParserSelection,
rust/src/chat/tests/chat.rs
+1
@@ -227,6 +227,7 @@ impl ChatBackend for FakeChatBackend {
227
self.tokenizer(),
228
options.tool_call_parser,
229
options.reasoning_parser,
230
+ options.tool_strict_level,
231
)?))
232
}
233
}
rust/src/chat/tests/roundtrip.rs
+1
@@ -717,6 +717,7 @@ async fn parse_completion(
717
let processor = backends.chat_backend.new_chat_output_processor(
718
&mut request,
719
NewChatOutputProcessorOptions {
720
+ tool_strict_level: vllm_chat::ToolStrictLevel::Auto,
721
tool_call_parser: &case.tool_call_parser,
722
reasoning_parser: &case.reasoning_parser,
723
},
rust/src/cmd/src/cli.rs
+16
@@ -24,6 +24,7 @@ use serde_with::{DefaultOnNull, OneOrMany, serde_as};
24
use thiserror_ext::AsReport as _;
25
use uuid::Uuid;
26
use vllm_chat::GenerationConfigMode;
27
+use vllm_chat::ToolStrictLevel;
28
use vllm_chat::multimodal::MmLimitPerPrompt;
29
use vllm_engine_core_client::TransportMode;
30
use vllm_managed_engine::ManagedEngineConfig;
@@ -134,6 +135,10 @@ pub struct RenderArgs {
135
/// `none` to disable parsing.
136
#[arg(long, default_value_t)]
137
reasoning_parser: ParserSelection,
138
+ /// Server-side floor for structural-tag based tool calling: `auto`,
139
+ /// `function`, or `parameter`.
140
+ #[arg(long, default_value_t)]
141
+ tool_strict_level: ToolStrictLevel,
142
/// Select the native chat renderer implementation.
143
#[arg(long = "tokenizer-mode", default_value_t)]
144
renderer: RendererSelection,
@@ -171,6 +176,7 @@ impl RenderArgs {
176
port: self.port,
177
tool_call_parser: self.tool_call_parser,
178
reasoning_parser: self.reasoning_parser,
179
+ tool_strict_level: self.tool_strict_level,
180
renderer: self.renderer,
181
chat_template: self.chat_template,
182
default_chat_template_kwargs: self.default_chat_template_kwargs.unwrap_or_default(),
@@ -241,6 +247,14 @@ pub struct SharedRuntimeArgs {
247
#[arg(long, default_value_t)]
248
#[serde(default = "default_py_bootstrap_parser_selection")]
249
pub reasoning_parser: ParserSelection,
250
+ /// Server-side floor for structural-tag based tool calling, applied on
251
+ /// top of the per-tool `strict` field: `auto` follows the request's
252
+ /// tool choice and per-tool strictness,
253
+ /// `function` constrains the tool-call envelope for every request with
254
+ /// tools, `parameter` additionally pins argument schemas.
255
+ #[arg(long, default_value_t)]
256
+ #[serde(default)]
257
+ pub tool_strict_level: ToolStrictLevel,
258
/// Select the chat renderer implementation.
259
#[arg(long = "tokenizer-mode", default_value_t)]
260
#[serde(default, rename = "tokenizer_mode")]
@@ -517,6 +531,7 @@ impl SharedRuntimeArgs {
531
listener_mode: HttpListenerMode::InheritedFd { fd: listen_fd },
532
tool_call_parser: self.tool_call_parser,
533
reasoning_parser: self.reasoning_parser,
534
+ tool_strict_level: self.tool_strict_level,
535
renderer: self.renderer,
536
language_model_only: self.language_model_only,
537
chat_template: self.chat_template,
@@ -574,6 +589,7 @@ impl SharedRuntimeArgs {
589
listener_mode,
590
tool_call_parser: self.tool_call_parser,
591
reasoning_parser: self.reasoning_parser,
592
+ tool_strict_level: self.tool_strict_level,
593
renderer: self.renderer,
594
language_model_only: self.language_model_only,
595
chat_template: self.chat_template,
rust/src/cmd/src/cli/tests.rs
+6
@@ -184,6 +184,7 @@ fn serve_args_forward_python_flags_with_separator() {
184
engine_ready_timeout_secs: 600,
185
tool_call_parser: Auto,
186
reasoning_parser: Auto,
187
+ tool_strict_level: Auto,
188
renderer: Auto,
189
language_model_only: false,
190
max_logprobs: None,
@@ -1023,6 +1024,7 @@ fn frontend_args_accept_json() {
1024
engine_ready_timeout_secs: 600,
1025
tool_call_parser: None,
1026
reasoning_parser: None,
1027
+ tool_strict_level: Auto,
1028
renderer: Auto,
1029
language_model_only: false,
1030
max_logprobs: None,
@@ -1693,6 +1695,7 @@ fn serve_args_accept_handshake_aliases() {
1695
engine_ready_timeout_secs: 600,
1696
tool_call_parser: Auto,
1697
reasoning_parser: Auto,
1698
+ tool_strict_level: Auto,
1699
renderer: Auto,
1700
language_model_only: false,
1701
max_logprobs: None,
@@ -1850,6 +1853,7 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1853
},
1854
tool_call_parser: Auto,
1855
reasoning_parser: Auto,
1856
+ tool_strict_level: Auto,
1857
renderer: Auto,
1858
language_model_only: false,
1859
chat_template: None,
@@ -1942,6 +1946,7 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1946
},
1947
tool_call_parser: Auto,
1948
reasoning_parser: Auto,
1949
+ tool_strict_level: Auto,
1950
renderer: Auto,
1951
language_model_only: false,
1952
chat_template: None,
@@ -2055,6 +2060,7 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
2060
},
2061
tool_call_parser: None,
2062
reasoning_parser: None,
2063
+ tool_strict_level: Auto,
2064
renderer: Auto,
2065
language_model_only: false,
2066
chat_template: None,
rust/src/server/examples/external_engine_openai_qwen.rs
+2
@@ -16,6 +16,7 @@ use clap::Parser;
16
use futures::StreamExt as _;
17
use tokio_util::sync::CancellationToken;
18
use tracing_subscriber::EnvFilter;
19
+use vllm_chat::ToolStrictLevel;
20
use vllm_engine_core_client::TransportMode;
21
use vllm_server::{
22
ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig,
@@ -70,6 +71,7 @@ async fn main() -> Result<()> {
71
},
72
tool_call_parser: ParserSelection::Auto,
73
reasoning_parser: ParserSelection::Auto,
74
+ tool_strict_level: ToolStrictLevel::Auto,
75
renderer: RendererSelection::Auto,
76
language_model_only: false,
77
chat_template: None,
rust/src/server/src/config.rs
+3
@@ -14,6 +14,7 @@ use serde_json::Value;
14
use vllm_chat::multimodal::MmLimitPerPrompt;
15
use vllm_chat::{
16
ChatTemplateContentFormatOption, GenerationConfigMode, ParserSelection, RendererSelection,
17
+ ToolStrictLevel,
18
};
19
use vllm_engine_core_client::{CoordinatorMode as EngineCoreCoordinatorMode, TransportMode};
20
use vllm_text::backend::hf::HfOverrides;
@@ -229,6 +230,8 @@ pub struct Config {
230
pub tool_call_parser: ParserSelection,
231
/// Reasoning parser selection.
232
pub reasoning_parser: ParserSelection,
233
+ /// Server-side floor for tool-call structural tags.
234
+ pub tool_strict_level: ToolStrictLevel,
235
/// Chat renderer selection.
236
pub renderer: RendererSelection,
237
/// Disable frontend-side multimodal preprocessing and render the model as
rust/src/server/src/grpc/tests.rs
+2
@@ -244,6 +244,7 @@ impl ChatBackend for FakeTextBackend {
244
self.tokenizer(),
245
options.tool_call_parser,
246
options.reasoning_parser,
247
+ options.tool_strict_level,
248
)?))
249
}
250
}
@@ -292,6 +293,7 @@ impl ChatBackend for FakeMultimodalBackend {
293
self.tokenizer(),
294
options.tool_call_parser,
295
options.reasoning_parser,
296
+ options.tool_strict_level,
297
)?))
298
}
299
}
rust/src/server/src/lib.rs
+2
-1
@@ -139,7 +139,8 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
139
140
let chat = ChatLlm::new(text, chat_backend)
141
.with_tool_call_parser(config.tool_call_parser.clone())
142
- .with_reasoning_parser(config.reasoning_parser.clone());
142
+ .with_reasoning_parser(config.reasoning_parser.clone())
143
+ .with_tool_strict_level(config.tool_strict_level);
144
145
let state = Arc::new(
146
AppState::new(served_model_names, chat)
rust/src/server/src/render.rs
+9
-5
@@ -10,7 +10,7 @@ use tokio_util::sync::CancellationToken;
10
use tracing::info;
11
use vllm_chat::{
12
ChatRequestProcessor, ChatTemplateContentFormatOption, LoadModelBackendsOptions,
13
- ParserSelection, RendererSelection, load_model_backends,
13
+ ParserSelection, RendererSelection, ToolStrictLevel, load_model_backends,
14
};
15
use vllm_text::TextRequestProcessor;
16
use vllm_text::backend::hf::HfOverrides;
@@ -32,6 +32,7 @@ pub struct RenderConfig {
32
pub port: u16,
33
pub tool_call_parser: ParserSelection,
34
pub reasoning_parser: ParserSelection,
35
+ pub tool_strict_level: ToolStrictLevel,
36
pub renderer: RendererSelection,
37
pub chat_template: Option<String>,
38
pub default_chat_template_kwargs: HashMap<String, Value>,
@@ -88,10 +89,12 @@ async fn build_state(config: &RenderConfig) -> Result<Arc<RenderState>> {
89
let max_model_len = config.max_model_len.unwrap_or(u32::MAX);
90
let text = TextRequestProcessor::new(loaded.text_backend, max_model_len)
91
.with_max_logprobs(config.max_logprobs);
91
- let chat = ChatRequestProcessor::render_only(loaded.chat_backend).with_parser_selections(
92
- config.tool_call_parser.clone(),
93
- config.reasoning_parser.clone(),
94
- );
92
+ let chat = ChatRequestProcessor::render_only(loaded.chat_backend)
93
+ .with_parser_selections(
94
+ config.tool_call_parser.clone(),
95
+ config.reasoning_parser.clone(),
96
+ )
97
+ .with_tool_strict_level(config.tool_strict_level);
98
Ok(Arc::new(RenderState {
99
model: config.model.clone(),
100
served_model_names,
@@ -154,6 +157,7 @@ mod tests {
157
shutdown.cancel();
158
let error = serve_render(
159
RenderConfig {
160
+ tool_strict_level: ToolStrictLevel::Auto,
161
model: "test-model".to_string(),
162
revision: None,
163
hf_overrides: Default::default(),
rust/src/server/src/routes/http_client_tests.rs
+1
@@ -168,6 +168,7 @@ impl ChatBackend for FakeChatBackend {
168
self.tokenizer(),
169
options.tool_call_parser,
170
options.reasoning_parser,
171
+ options.tool_strict_level,
172
)?))
173
}
174
}
rust/src/server/src/routes/tests.rs
+1
@@ -517,6 +517,7 @@ impl ChatBackend for FakeChatBackend {
517
self.tokenizer(),
518
options.tool_call_parser,
519
options.reasoning_parser,
520
+ options.tool_strict_level,
521
)?))
522
}
523
}
tests/tool_parsers/test_structural_tag_registry.py
+250
-4
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import json
5
from types import SimpleNamespace
6
from unittest.mock import MagicMock
7
@@ -33,9 +34,11 @@ from vllm.tool_parsers.structural_tag_registry import (
34
SUPPORTED_STRUCTURAL_TAG_MODELS,
35
VLLM_BUILTIN_STRUCTURAL_TAG_MODELS,
36
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS,
37
+ ToolChoice,
38
get_function_parameters,
39
get_model_structural_tag,
40
)
41
+from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
42
43
44
@pytest.fixture
@@ -231,11 +234,11 @@ def test_get_model_structural_tag_supports_all_xgrammar_builtins(
234
235
236
def test_get_model_structural_tag_supports_vllm_hermes(
234
- sample_tools: list[ChatCompletionToolsParam],
237
+ sample_tools_strict: list[ChatCompletionToolsParam],
238
):
239
tag = get_model_structural_tag(
240
model="hermes",
238
- tools=sample_tools,
241
+ tools=sample_tools_strict,
242
tool_choice="required",
243
reasoning=False,
244
)
@@ -273,6 +276,22 @@ def test_get_model_structural_tag_supports_vllm_hermes(
276
assert content["json_schema"] == expected_schema
277
278
279
+def test_hermes_required_without_strict_leaves_arguments_free(
280
+ sample_tools: list[ChatCompletionToolsParam],
281
+):
282
+ tag = get_model_structural_tag(
283
+ model="hermes",
284
+ tools=sample_tools,
285
+ tool_choice="required",
286
+ reasoning=False,
287
+ )
288
+
289
+ assert isinstance(tag, StructuralTag)
290
+ tags = tag.model_dump()["format"]["tags"]
291
+ assert tags
292
+ assert all(tag_dump["content"]["json_schema"] is True for tag_dump in tags)
293
+
294
+
295
def test_hermes_required_tool_calls_use_empty_separator():
296
tools = [
297
ChatCompletionToolsParam(
@@ -361,10 +380,19 @@ def _k3_tools(*calls: str) -> str:
380
return f"{_K3_TOOLS_OPEN}{''.join(calls)}{_K3_TOOLS_CLOSE}"
381
382
364
-def _k3_grammar(tool_choice, tools=None):
383
+def _as_strict(tools: list[ChatCompletionToolsParam]) -> list[ChatCompletionToolsParam]:
384
+ for tool in tools:
385
+ tool.function.strict = True
386
+ return tools
387
+
388
+
389
+def _k3_grammar(tool_choice, tools=None, *, strict: bool = True):
390
+ """Build the K3 grammar; tools are strict by default so the typed
391
+ argument channel is exercised (an unset ``strict`` leaves arguments free)."""
392
+ tools = tools if tools is not None else _k3_tools_by_name()
393
tag = get_model_structural_tag(
394
model="kimi_k3",
367
- tools=tools if tools is not None else _k3_tools_by_name(),
395
+ tools=_as_strict(tools) if strict else tools,
396
tool_choice=tool_choice,
397
reasoning=False,
398
)
@@ -467,6 +495,18 @@ def test_kimi_k3_required_rejects_invalid(body: str):
495
assert not _is_grammar_accept_string(_k3_grammar("required"), body)
496
497
498
+def test_kimi_k3_required_without_strict_leaves_arguments_free():
499
+ # An unset ``strict`` pins the call envelope only: a value the schema
500
+ # would reject is accepted, and the declared-required argument may be
501
+ # omitted.
502
+ grammar = _k3_grammar("required", strict=False)
503
+ body = _k3_response() + _k3_tools(
504
+ _k3_call("get_weather", _k3_arg("days", "number", "abc"))
505
+ )
506
+ assert _is_grammar_accept_string(grammar, body)
507
+ assert not _is_grammar_accept_string(_k3_grammar("required"), body)
508
+
509
+
510
def test_kimi_k3_schema_without_required_accepts_empty_call():
511
tools = [
512
ChatCompletionToolsParam(
@@ -829,3 +869,209 @@ def test_kimi_k3_forced_tool_choice_builds_single_mandatory_call():
869
response_only = _k3_response("no call here")
870
assert _is_grammar_accept_string(grammar, ok)
871
assert not _is_grammar_accept_string(grammar, response_only)
872
+
873
+
874
+def _pins_argument_schema(tag: StructuralTag) -> bool:
875
+ return '"json_schema": {' in json.dumps(tag.model_dump(), ensure_ascii=False)
876
+
877
+
878
+def _dumped(tag: StructuralTag) -> str:
879
+ return json.dumps(tag.model_dump(), ensure_ascii=False)
880
+
881
+
882
+def test_tool_strict_level_auto_is_the_default(
883
+ sample_tools: list[ChatCompletionToolsParam],
884
+):
885
+ """Auto + no strict tool gets no tag unless the operator raises the floor."""
886
+ assert (
887
+ get_model_structural_tag(
888
+ model="deepseek_v4",
889
+ tools=sample_tools,
890
+ tool_choice="auto",
891
+ reasoning=False,
892
+ )
893
+ is None
894
+ )
895
+
896
+
897
+@pytest.mark.parametrize("model", sorted(SUPPORTED_STRUCTURAL_TAG_MODELS))
898
+def test_tool_strict_level_function_lifts_auto_gate(
899
+ model: str,
900
+ sample_tools: list[ChatCompletionToolsParam],
901
+):
902
+ tag = get_model_structural_tag(
903
+ model=model,
904
+ tools=sample_tools,
905
+ tool_choice="auto",
906
+ reasoning=False,
907
+ strict_level=ToolStrictLevel.FUNCTION,
908
+ )
909
+
910
+ assert tag is not None
911
+
912
+
913
+@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS))
914
+def test_tool_strict_level_function_pins_envelope_only(
915
+ model: str,
916
+ sample_tools: list[ChatCompletionToolsParam],
917
+):
918
+ """The request's own tools stay untouched; unset ``strict`` stays free."""
919
+ tag = get_model_structural_tag(
920
+ model=model,
921
+ tools=sample_tools,
922
+ tool_choice="auto",
923
+ reasoning=False,
924
+ strict_level=ToolStrictLevel.FUNCTION,
925
+ )
926
+
927
+ assert tag is not None
928
+ assert not _pins_argument_schema(tag)
929
+ assert all(tool.function.strict is None for tool in sample_tools)
930
+
931
+
932
+@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS))
933
+def test_tool_strict_level_parameter_pins_argument_schemas(
934
+ model: str,
935
+ sample_tools: list[ChatCompletionToolsParam],
936
+):
937
+ tag = get_model_structural_tag(
938
+ model=model,
939
+ tools=sample_tools,
940
+ tool_choice="auto",
941
+ reasoning=False,
942
+ strict_level=ToolStrictLevel.PARAMETER,
943
+ )
944
+
945
+ assert tag is not None
946
+ assert _pins_argument_schema(tag)
947
+
948
+
949
+def test_tool_strict_level_function_keeps_client_strict_tools_strict(
950
+ sample_tools_strict: list[ChatCompletionToolsParam],
951
+):
952
+ tag = get_model_structural_tag(
953
+ model="deepseek_v4",
954
+ tools=sample_tools_strict,
955
+ tool_choice="auto",
956
+ reasoning=False,
957
+ strict_level=ToolStrictLevel.FUNCTION,
958
+ )
959
+
960
+ assert tag is not None
961
+ assert _pins_argument_schema(tag)
962
+
963
+
964
+@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS))
965
+def test_absent_strict_is_not_pinned_next_to_a_strict_tool(model: str):
966
+ """A strict tool must not drag a neighbour with unset ``strict`` into
967
+ schema enforcement."""
968
+ weather = ChatCompletionToolsParam(
969
+ type="function",
970
+ function={
971
+ "name": "get_weather",
972
+ "strict": True,
973
+ "parameters": {
974
+ "type": "object",
975
+ "properties": {"city": {"type": "string"}},
976
+ "required": ["city"],
977
+ },
978
+ },
979
+ )
980
+ search = ChatCompletionToolsParam(
981
+ type="function",
982
+ function={
983
+ "name": "search",
984
+ "parameters": {
985
+ "type": "object",
986
+ "properties": {"query": {"type": "string"}},
987
+ "required": ["query"],
988
+ },
989
+ },
990
+ )
991
+ tag = get_model_structural_tag(
992
+ model=model,
993
+ tools=[weather, search],
994
+ tool_choice="auto",
995
+ reasoning=False,
996
+ )
997
+
998
+ assert tag is not None
999
+ dumped = _dumped(tag)
1000
+ assert '"city"' in dumped
1001
+ assert '"query"' not in dumped
1002
+
1003
+
1004
+@pytest.mark.parametrize(
1005
+ "tool_choice",
1006
+ [
1007
+ "required",
1008
+ ChatCompletionNamedToolChoiceParam(
1009
+ function=ChatCompletionNamedFunction(name="get_weather")
1010
+ ),
1011
+ ],
1012
+)
1013
+@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS))
1014
+def test_forced_tool_choice_pins_schema_only_for_strict_tools(
1015
+ model: str,
1016
+ tool_choice: ToolChoice,
1017
+ sample_tools: list[ChatCompletionToolsParam],
1018
+ sample_tools_strict: list[ChatCompletionToolsParam],
1019
+):
1020
+ """Required / named always constrain the call; the argument schema
1021
+ follows the per-tool ``strict`` (or the parameter level)."""
1022
+ free = get_model_structural_tag(
1023
+ model=model,
1024
+ tools=sample_tools,
1025
+ tool_choice=tool_choice,
1026
+ reasoning=False,
1027
+ strict_level=ToolStrictLevel.FUNCTION,
1028
+ )
1029
+ pinned = get_model_structural_tag(
1030
+ model=model,
1031
+ tools=sample_tools_strict,
1032
+ tool_choice=tool_choice,
1033
+ reasoning=False,
1034
+ )
1035
+ floored = get_model_structural_tag(
1036
+ model=model,
1037
+ tools=sample_tools,
1038
+ tool_choice=tool_choice,
1039
+ reasoning=False,
1040
+ strict_level=ToolStrictLevel.PARAMETER,
1041
+ )
1042
+
1043
+ assert free is not None and not _pins_argument_schema(free)
1044
+ assert pinned is not None and _pins_argument_schema(pinned)
1045
+ assert floored is not None and _pins_argument_schema(floored)
1046
+
1047
+
1048
+def test_tool_strict_level_parameter_overrides_client_strict_false():
1049
+ tools = [
1050
+ ChatCompletionToolsParam(
1051
+ type="function",
1052
+ function={
1053
+ "name": "get_weather",
1054
+ "parameters": {"type": "object", "properties": {}},
1055
+ "strict": False,
1056
+ },
1057
+ )
1058
+ ]
1059
+ tag = get_model_structural_tag(
1060
+ model="deepseek_v4",
1061
+ tools=tools,
1062
+ tool_choice="required",
1063
+ reasoning=False,
1064
+ strict_level=ToolStrictLevel.PARAMETER,
1065
+ )
1066
+
1067
+ assert tag is not None
1068
+ assert _pins_argument_schema(tag)
1069
+
1070
+
1071
+def test_tool_strict_level_from_name():
1072
+ assert ToolStrictLevel.from_name("AUTO") is ToolStrictLevel.AUTO
1073
+ assert ToolStrictLevel.from_name("Parameter") is ToolStrictLevel.PARAMETER
1074
+ with pytest.raises(ValueError, match="expected one of auto, function, parameter"):
1075
+ ToolStrictLevel.from_name("strict")
1076
+ with pytest.raises(ValueError, match="expected one of auto, function, parameter"):
1077
+ ToolStrictLevel.from_name("off")
vllm/entrypoints/anthropic/serving.py
+2
@@ -112,6 +112,7 @@ class AnthropicServingMessages(OpenAIServingChat):
112
reasoning_parser: str = "",
113
enable_auto_tools: bool = False,
114
tool_parser: str | None = None,
115
+ tool_strict_level: str = "auto",
116
enable_prompt_tokens_details: bool = False,
117
enable_force_include_usage: bool = False,
118
default_chat_template_kwargs: dict[str, Any] | None = None,
@@ -127,6 +128,7 @@ class AnthropicServingMessages(OpenAIServingChat):
128
return_tokens_as_token_ids=return_tokens_as_token_ids,
129
reasoning_parser=reasoning_parser,
130
enable_auto_tools=enable_auto_tools,
131
+ tool_strict_level=tool_strict_level,
132
tool_parser=tool_parser,
133
enable_prompt_tokens_details=enable_prompt_tokens_details,
134
enable_force_include_usage=enable_force_include_usage,
vllm/entrypoints/cohere/serving.py
+2
@@ -249,6 +249,7 @@ class CohereServingChatV2(OpenAIServingChat):
249
reasoning_parser: str = "",
250
enable_auto_tools: bool = False,
251
tool_parser: str | None = None,
252
+ tool_strict_level: str = "auto",
253
enable_prompt_tokens_details: bool = False,
254
enable_force_include_usage: bool = False,
255
default_chat_template_kwargs: dict[str, Any] | None = None,
@@ -265,6 +266,7 @@ class CohereServingChatV2(OpenAIServingChat):
266
return_tokens_as_token_ids=return_tokens_as_token_ids,
267
reasoning_parser=reasoning_parser,
268
enable_auto_tools=enable_auto_tools,
269
+ tool_strict_level=tool_strict_level,
270
tool_parser=tool_parser,
271
enable_prompt_tokens_details=enable_prompt_tokens_details,
272
enable_force_include_usage=enable_force_include_usage,
vllm/entrypoints/generate/api_router.py
+4
@@ -112,6 +112,7 @@ async def init_generate_state(
112
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
113
enable_auto_tools=args.enable_auto_tool_choice,
114
tool_parser=args.tool_call_parser,
115
+ tool_strict_level=args.tool_strict_level,
116
tool_server=state.tool_server,
117
reasoning_parser=args.structured_outputs_config.reasoning_parser,
118
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
@@ -137,6 +138,7 @@ async def init_generate_state(
138
enable_auto_tools=args.enable_auto_tool_choice,
139
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
140
tool_parser=args.tool_call_parser,
141
+ tool_strict_level=args.tool_strict_level,
142
reasoning_parser=args.structured_outputs_config.reasoning_parser,
143
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
144
enable_force_include_usage=args.enable_force_include_usage,
@@ -178,6 +180,7 @@ async def init_generate_state(
180
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
181
enable_auto_tools=args.enable_auto_tool_choice,
182
tool_parser=args.tool_call_parser,
183
+ tool_strict_level=args.tool_strict_level,
184
reasoning_parser=args.structured_outputs_config.reasoning_parser,
185
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
186
enable_force_include_usage=args.enable_force_include_usage,
@@ -198,6 +201,7 @@ async def init_generate_state(
201
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
202
enable_auto_tools=args.enable_auto_tool_choice,
203
tool_parser=args.tool_call_parser,
204
+ tool_strict_level=args.tool_strict_level,
205
reasoning_parser=args.structured_outputs_config.reasoning_parser,
206
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
207
enable_force_include_usage=args.enable_force_include_usage,
vllm/entrypoints/launchers/api_server/app_state.py
+2
@@ -95,6 +95,7 @@ async def init_app_state(
95
enable_auto_tools=args.enable_auto_tool_choice,
96
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
97
tool_parser=args.tool_call_parser,
98
+ tool_strict_level=args.tool_strict_level,
99
reasoning_parser=args.structured_outputs_config.reasoning_parser,
100
default_chat_template_kwargs=default_chat_template_kwargs,
101
log_error_stack=args.log_error_stack,
@@ -111,6 +112,7 @@ async def init_app_state(
112
enable_auto_tools=args.enable_auto_tool_choice,
113
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
114
tool_parser=args.tool_call_parser,
115
+ tool_strict_level=args.tool_strict_level,
116
reasoning_parser=args.structured_outputs_config.reasoning_parser,
117
default_chat_template_kwargs=default_chat_template_kwargs,
118
log_error_stack=args.log_error_stack,
vllm/entrypoints/launchers/cli_args.py
+8
@@ -20,6 +20,7 @@ from vllm.entrypoints.chat_utils import (
20
)
21
from vllm.entrypoints.openai.models.protocol import LoRAModulePath
22
from vllm.tool_parsers import ToolParserManager
23
+from vllm.tool_parsers.tool_strict_level import ToolStrictLevelName
24
from vllm.utils.argparse_utils import FlexibleArgumentParser
25
26
from .utils.constants import (
@@ -116,6 +117,13 @@ class BaseFrontendArgs:
117
"""Special the tool parser plugin write to parse the model-generated tool
118
into OpenAI API format, the name register in this plugin can be used in
119
`--tool-call-parser`."""
120
+ tool_strict_level: ToolStrictLevelName = "auto"
121
+ """Server-side floor for structural-tag based tool calling, applied on top
122
+ of the per-tool `strict` field. `auto` follows the request's tool choice
123
+ and per-tool strictness; `function` constrains the
124
+ tool-call envelope (markup and function name) for every request with
125
+ tools; `parameter` additionally pins argument schemas, as if every tool
126
+ were `strict: true`."""
127
tool_server: str | None = None
128
"""Comma-separated list of host:port pairs (IPv4, IPv6, or hostname).
129
Examples: 127.0.0.1:8000, [::1]:8000, localhost:1234. Or `demo` for
vllm/entrypoints/launchers/render/app_state.py
+2
@@ -60,6 +60,7 @@ async def init_render_app_state(
60
enable_auto_tools=args.enable_auto_tool_choice,
61
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
62
tool_parser=args.tool_call_parser,
63
+ tool_strict_level=args.tool_strict_level,
64
reasoning_parser=args.reasoning_parser,
65
default_chat_template_kwargs=default_chat_template_kwargs,
66
log_error_stack=args.log_error_stack,
@@ -76,6 +77,7 @@ async def init_render_app_state(
77
enable_auto_tools=args.enable_auto_tool_choice,
78
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
79
tool_parser=args.tool_call_parser,
80
+ tool_strict_level=args.tool_strict_level,
81
reasoning_parser=args.reasoning_parser,
82
default_chat_template_kwargs=default_chat_template_kwargs,
83
log_error_stack=args.log_error_stack,
vllm/entrypoints/openai/chat_completion/serving.py
+2
@@ -132,6 +132,7 @@ class OpenAIServingChat(GenerateBaseServing):
132
enable_auto_tools: bool = False,
133
exclude_tools_when_tool_choice_none: bool = False,
134
tool_parser: str | None = None,
135
+ tool_strict_level: str = "auto",
136
enable_prompt_tokens_details: bool = False,
137
enable_force_include_usage: bool = False,
138
enable_log_outputs: bool = False,
@@ -161,6 +162,7 @@ class OpenAIServingChat(GenerateBaseServing):
162
tool_parser_name=tool_parser,
163
reasoning_parser_name=reasoning_parser,
164
enable_auto_tools=enable_auto_tools,
165
+ tool_strict_level=tool_strict_level,
166
model_name=self.model_config.model,
167
is_harmony=self.model_config.hf_config.model_type == "gpt_oss",
168
)
vllm/entrypoints/openai/responses/serving.py
+2
@@ -109,6 +109,7 @@ class OpenAIServingResponses(GenerateBaseServing):
109
reasoning_parser: str = "",
110
enable_auto_tools: bool = False,
111
tool_parser: str | None = None,
112
+ tool_strict_level: str = "auto",
113
tool_server: ToolServer | None = None,
114
enable_prompt_tokens_details: bool = False,
115
enable_force_include_usage: bool = False,
@@ -135,6 +136,7 @@ class OpenAIServingResponses(GenerateBaseServing):
136
tool_parser_name=tool_parser,
137
reasoning_parser_name=reasoning_parser,
138
enable_auto_tools=enable_auto_tools,
139
+ tool_strict_level=tool_strict_level,
140
model_name=self.model_config.model,
141
is_harmony=self.model_config.hf_config.model_type == "gpt_oss",
142
)
vllm/parser/abstract_parser.py
+4
@@ -36,6 +36,7 @@ from vllm.tool_parsers.streaming import (
36
extract_named_tool_call_streaming,
37
extract_required_tool_call_streaming,
38
)
39
+from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
40
41
logger = init_logger(__name__)
42
@@ -95,6 +96,8 @@ class Parser:
96
# Subclasses should override these if they use specific parser classes
97
reasoning_parser_cls: type[ReasoningParser] | None = None
98
tool_parser_cls: type[ToolParser] | None = None
99
+ # Server-side floor for tool-call structural tags (--tool-strict-level).
100
+ tool_strict_level: ToolStrictLevel = ToolStrictLevel.AUTO
101
102
def __init__(
103
self,
@@ -415,6 +418,7 @@ class DelegatingParser(Parser):
418
structure_tag = self._tool_parser.get_structural_tag(
419
request,
420
reasoning=False,
421
+ strict_level=self.tool_strict_level,
422
)
423
if structure_tag is None:
424
return request
vllm/parser/parser_manager.py
+10
@@ -6,6 +6,7 @@ from __future__ import annotations
6
from typing import TYPE_CHECKING
7
8
from vllm.logger import init_logger
9
+from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
10
11
if TYPE_CHECKING:
12
from vllm.parser.abstract_parser import Parser
@@ -77,6 +78,7 @@ class ParserManager:
78
enable_auto_tools: bool = False,
79
model_name: str | None = None,
80
is_harmony: bool = False,
81
+ tool_strict_level: str = "auto",
82
) -> type[Parser] | None:
83
"""Get a Parser that handles both reasoning and tool parsing.
84
@@ -89,6 +91,8 @@ class ParserManager:
91
model_name: The model name for parser-specific warnings.
92
is_harmony: Whether the selected model uses the Harmony format.
93
If True, HarmonyParser is always returned.
94
+ tool_strict_level: Server-side floor for tool-call structural
95
+ tags (``--tool-strict-level``).
96
97
Returns:
98
A Parser class, or None if neither parser is specified.
@@ -105,11 +109,14 @@ class ParserManager:
109
if reasoning_parser_cls is None and tool_parser_cls is None:
110
return None
111
112
+ strict_level = ToolStrictLevel.from_name(tool_strict_level)
113
+
114
if is_harmony:
115
from vllm.parser.harmony import HarmonyParser
116
117
HarmonyParser.reasoning_parser_cls = reasoning_parser_cls
118
HarmonyParser.tool_parser_cls = tool_parser_cls
119
+ HarmonyParser.tool_strict_level = strict_level
120
return HarmonyParser
121
122
if reasoning_parser_name == "kimi_k3" or tool_parser_name == "kimi_k3":
@@ -121,6 +128,7 @@ class ParserManager:
128
class _KimiK3Parser(KimiK3Parser):
129
reasoning_parser_cls = r_cls
130
tool_parser_cls = t_cls
131
+ tool_strict_level = strict_level
132
133
return _KimiK3Parser
134
@@ -136,6 +144,7 @@ class ParserManager:
144
class _CohereCommandParser(CohereCommandParser):
145
reasoning_parser_cls = r_cls
146
tool_parser_cls = t_cls
147
+ tool_strict_level = strict_level
148
149
return _CohereCommandParser
150
@@ -147,5 +156,6 @@ class ParserManager:
156
class _Parser(DelegatingParser):
157
reasoning_parser_cls = r_cls
158
tool_parser_cls = t_cls
159
+ tool_strict_level = strict_level
160
161
return _Parser
vllm/renderers/online_derenderer.py
+2
@@ -54,6 +54,7 @@ class OnlineDerenderer:
54
exclude_tools_when_tool_choice_none: bool = False,
55
tool_parser: str | None = None,
56
reasoning_parser: str | None = None,
57
+ tool_strict_level: str = "auto",
58
default_chat_template_kwargs: dict[str, Any] | None = None,
59
log_error_stack: bool = False,
60
) -> None:
@@ -68,6 +69,7 @@ class OnlineDerenderer:
69
tool_parser_name=tool_parser,
70
reasoning_parser_name=reasoning_parser,
71
enable_auto_tools=enable_auto_tools,
72
+ tool_strict_level=tool_strict_level,
73
model_name=model_config.model,
74
is_harmony=self.use_harmony,
75
)
vllm/renderers/online_renderer.py
+2
@@ -127,6 +127,7 @@ class OnlineRenderer:
127
exclude_tools_when_tool_choice_none: bool = False,
128
tool_parser: str | None = None,
129
reasoning_parser: str | None = None,
130
+ tool_strict_level: str = "auto",
131
default_chat_template_kwargs: dict[str, Any] | None = None,
132
log_error_stack: bool = False,
133
) -> None:
@@ -141,6 +142,7 @@ class OnlineRenderer:
142
tool_parser_name=tool_parser,
143
reasoning_parser_name=reasoning_parser,
144
enable_auto_tools=enable_auto_tools,
145
+ tool_strict_level=tool_strict_level,
146
model_name=model_config.model,
147
is_harmony=self.use_harmony,
148
)
vllm/tool_parsers/abstract_tool_parser.py
+3
@@ -30,6 +30,7 @@ from vllm.sampling_params import (
30
StructuredOutputsParams,
31
)
32
from vllm.tokenizers import TokenizerLike
33
+from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
34
from vllm.tool_parsers.utils import Tool, get_json_schema_from_tools
35
from vllm.utils.collection_utils import is_list_of
36
from vllm.utils.import_utils import import_plugin
@@ -168,6 +169,7 @@ class ToolParser:
169
request: ChatCompletionRequest | ResponsesRequest,
170
*,
171
reasoning: bool = False,
172
+ strict_level: ToolStrictLevel = ToolStrictLevel.AUTO,
173
):
174
if self.structural_tag_model is None:
175
return None
@@ -180,6 +182,7 @@ class ToolParser:
182
tools=request.tools,
183
tool_choice=request.tool_choice,
184
reasoning=reasoning,
185
+ strict_level=strict_level,
186
)
187
188
def extract_tool_calls(
vllm/tool_parsers/hy_v4_tool_parser.py
+4
@@ -25,6 +25,7 @@ from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
25
from vllm.logger import init_logger
26
from vllm.tokenizers import TokenizerLike
27
from vllm.tool_parsers.abstract_tool_parser import ToolParser
28
+from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
29
from vllm.tool_parsers.utils import partial_tag_overlap
30
31
if TYPE_CHECKING:
@@ -994,6 +995,7 @@ class HYV4ToolParser(ToolParser):
995
request: ChatCompletionRequest | ResponsesRequest,
996
*,
997
reasoning: bool = False,
998
+ strict_level: ToolStrictLevel = ToolStrictLevel.AUTO,
999
) -> StructuralTag | None:
1000
"""Build a structural tag matching HYV4's tool tokens.
1001
@@ -1009,6 +1011,7 @@ class HYV4ToolParser(ToolParser):
1011
Args:
1012
request: The request being adjusted.
1013
reasoning: Whether the grammar also covers the reasoning phase.
1014
+ strict_level: Server-side floor from ``--tool-strict-level``.
1015
1016
Returns:
1017
The structural tag, or None when structural tagging does not apply.
@@ -1041,6 +1044,7 @@ class HYV4ToolParser(ToolParser):
1044
tool_choice=request.tool_choice,
1045
reasoning=reasoning,
1046
token_suffix=self._extractor.token_suffix,
1047
+ strict_level=strict_level,
1048
)
1049
except Exception:
1050
logger.warning(
vllm/tool_parsers/structural_tag_registry.py
+52
-7
@@ -35,6 +35,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
35
ChatCompletionNamedToolChoiceParam,
36
ChatCompletionToolsParam,
37
)
38
+from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
39
40
ToolChoice: TypeAlias = (
41
Literal["none", "auto", "required"]
@@ -94,15 +95,57 @@ def register_vllm_structural_tag(
95
return decorator
96
97
98
+def _tool_is_strict(tool: ChatCompletionToolsParam | ResponsesTool) -> bool:
99
+ if isinstance(tool, FunctionTool):
100
+ return tool.strict is True
101
+ if isinstance(tool, ChatCompletionToolsParam):
102
+ return tool.function.strict is True
103
+ return False
104
+
105
+
106
def _any_tool_strict(
107
tools: Sequence[ChatCompletionToolsParam | ResponsesTool],
108
) -> bool:
100
- for tool in tools:
101
- if isinstance(tool, FunctionTool) and tool.strict is True:
102
- return True
103
- if isinstance(tool, ChatCompletionToolsParam) and tool.function.strict is True:
104
- return True
105
- return False
109
+ return any(_tool_is_strict(tool) for tool in tools)
110
+
111
+
112
+def _with_tool_strict(
113
+ tool: ChatCompletionToolsParam | ResponsesTool,
114
+ strict: bool,
115
+) -> ChatCompletionToolsParam | ResponsesTool:
116
+ """Return a copy of ``tool`` with ``strict`` set, leaving the request's alone."""
117
+ if isinstance(tool, FunctionTool):
118
+ return tool.model_copy(update={"strict": strict})
119
+ if isinstance(tool, ChatCompletionToolsParam):
120
+ return tool.model_copy(
121
+ update={"function": tool.function.model_copy(update={"strict": strict})}
122
+ )
123
+ return tool
124
+
125
+
126
+def _resolve_tool_strictness(
127
+ tools: Sequence[ChatCompletionToolsParam | ResponsesTool],
128
+ tool_choice: ToolChoice,
129
+ strict_level: ToolStrictLevel,
130
+) -> Sequence[ChatCompletionToolsParam | ResponsesTool] | None:
131
+ """Decide whether a structural tag applies and pin each tool's ``strict``.
132
+
133
+ A tool without an explicit ``strict`` is treated as non-strict: its call
134
+ envelope is still constrained, but its arguments stay free unless the
135
+ server level is PARAMETER. ``None`` means no structural tag.
136
+ """
137
+ if (
138
+ tool_choice == "auto"
139
+ and strict_level == ToolStrictLevel.AUTO
140
+ and not _any_tool_strict(tools)
141
+ ):
142
+ return None
143
+ return [
144
+ _with_tool_strict(
145
+ tool, strict_level >= ToolStrictLevel.PARAMETER or _tool_is_strict(tool)
146
+ )
147
+ for tool in tools
148
+ ]
149
150
151
def get_model_structural_tag(
@@ -111,12 +154,14 @@ def get_model_structural_tag(
154
tool_choice: ToolChoice,
155
reasoning: bool,
156
token_suffix: str = "",
157
+ strict_level: ToolStrictLevel = ToolStrictLevel.AUTO,
158
) -> StructuralTag | None:
159
"""Build a structural tag with xgrammar's builtin model templates."""
160
if not tools or tool_choice == "none":
161
return None
162
119
- if tool_choice == "auto" and not _any_tool_strict(tools):
163
+ tools = _resolve_tool_strictness(tools, tool_choice, strict_level)
164
+ if tools is None:
165
return None
166
167
dumped_tools = [_dump_tool_for_xgrammar(tool) for tool in tools]
vllm/tool_parsers/tool_strict_level.py
new
+32
@@ -0,0 +1,32 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import enum
5
+from typing import Literal
6
+
7
+ToolStrictLevelName = Literal["auto", "function", "parameter"]
8
+
9
+
10
+class ToolStrictLevel(enum.IntEnum):
11
+ """Server-side floor for tool-call structural tags (``--tool-strict-level``).
12
+
13
+ AUTO: derive call constraints from ``tool_choice`` and argument-schema
14
+ enforcement from each tool's explicit ``strict`` field.
15
+ FUNCTION: constrain the tool-call envelope for every request with tools.
16
+ PARAMETER: additionally pin argument schemas, as if every tool were
17
+ ``strict: true``.
18
+ """
19
+
20
+ AUTO = 0
21
+ FUNCTION = 1
22
+ PARAMETER = 2
23
+
24
+ @classmethod
25
+ def from_name(cls, name: str) -> "ToolStrictLevel":
26
+ try:
27
+ return cls[name.strip().upper()]
28
+ except KeyError:
29
+ expected = ", ".join(level.name.lower() for level in cls)
30
+ raise ValueError(
31
+ f"Unknown tool strict level {name!r}; expected one of {expected}."
32
+ ) from None