[Rust Frontend] Introduce parser-owned output grammar interfaces (#55269)
Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Bugen Zhao committed
Sep 22, 2026 at 13:35 UTC
d50723df04f77c732c75e07d337ee81d843e014f
34 files changed
+814
-698
rust/Cargo.lock
+1
@@ -5937,6 +5937,7 @@ dependencies = [
5937
"openai-protocol",
5938
"serde",
5939
"serde_json",
5940
+ "serde_with",
5941
"thiserror 2.0.18",
5942
"thiserror-ext",
5943
"tool-parser",
rust/src/chat/src/error.rs
+10
-2
@@ -60,6 +60,11 @@ pub enum Error {
60
#[source]
61
error: BoxedError,
62
},
63
+ #[error("failed to initialize request output parser")]
64
+ OutputParserInitialization {
65
+ #[source]
66
+ error: BoxedError,
67
+ },
68
#[error(
69
"gpt_oss uses native Harmony output parsing; generic {kind} parser override `{selection}` is not supported"
70
)]
@@ -87,8 +92,11 @@ pub enum Error {
92
ToolChoiceRequiresTools,
93
#[error("tool_choice function `{name}` was not found in the available tools")]
94
ToolChoiceFunctionNotFound { name: String },
90
- #[error("failed to build structural tag: {message}")]
91
- StructuralTag { message: String },
95
+ #[error("failed to build output grammar")]
96
+ OutputGrammar {
97
+ #[source]
98
+ error: BoxedError,
99
+ },
100
#[error(transparent)]
101
Text(#[from] vllm_text::Error),
102
#[error(transparent)]
rust/src/chat/src/lib.rs
+29
-14
@@ -22,6 +22,7 @@ pub use event::{
22
};
23
use futures::{StreamExt, TryStreamExt as _};
24
pub use llm_multimodal::MediaContentPart;
25
+use output::apply_output_grammar;
26
pub use output::{
27
ChatOutputProcessor, DefaultChatOutputProcessor, DynChatOutputProcessor,
28
HarmonyChatOutputProcessor,
@@ -65,7 +66,7 @@ use vllm_engine_core_client::EngineCoreClient;
66
use vllm_engine_core_client::protocol::dtype::ModelDtype;
67
use vllm_engine_core_client::protocol::request::ReasoningParserKwargs;
68
use vllm_llm::Llm;
68
-use vllm_text::{Prompt, TextLlm, TextRequest};
69
+use vllm_text::{Prompt, TextLlm, TextRequest, TextRequestProcessor};
70
71
/// Chat request preparation shared by inference and render-only frontends.
72
pub struct ChatRequestProcessor {
@@ -174,15 +175,8 @@ impl ChatRequestProcessor {
175
async fn prepare_text_request(&self, request: ChatRequest) -> Result<TextRequest> {
176
// Stamp before rendering so render and tokenize count toward TTFT/e2e.
177
let arrival_time = vllm_llm::current_unix_timestamp_secs();
177
- let rendered = self.backend.chat_renderer().render(&request)?;
178
- let reasoning_parser_kwargs =
179
- request
180
- .sampling_params
181
- .structured_outputs
182
- .is_some()
183
- .then(|| ReasoningParserKwargs {
184
- chat_template_kwargs: rendered.effective_template_kwargs.clone(),
185
- });
178
+ let mut rendered = self.backend.chat_renderer().render(&request)?;
179
+ let chat_template_kwargs = std::mem::take(&mut rendered.effective_template_kwargs);
180
let (prompt, mm_features) = self.finalize_rendered_prompt(&request, rendered).await?;
181
Ok(TextRequest {
182
request_id: request.request_id,
@@ -198,7 +192,10 @@ impl ChatRequestProcessor {
192
data_parallel_rank: request.data_parallel_rank,
193
session_id: request.session_id,
194
kv_hints: None,
201
- reasoning_parser_kwargs,
195
+ reasoning_parser_kwargs: ReasoningParserKwargs {
196
+ chat_template_kwargs,
197
+ },
198
+ reasoning_ended: None,
199
lora_request: request.lora_request,
200
arrival_time: Some(arrival_time),
201
})
@@ -211,12 +208,19 @@ impl ChatRequestProcessor {
208
}
209
210
/// Prepare one chat request without submitting it to an engine.
211
+ ///
212
+ /// The returned text request is already tokenized: the output processor
213
+ /// is initialized from the final prompt token IDs and its grammar, if any,
214
+ /// is applied to the request before it is handed to `text_processor` again
215
+ /// for lowering.
216
pub async fn prepare(
217
&self,
218
mut request: ChatRequest,
219
+ text_processor: &TextRequestProcessor,
220
) -> Result<(TextRequest, DynChatOutputProcessor)> {
221
request.validate()?;
219
- let output_processor = self.backend.new_chat_output_processor(
222
+
223
+ let mut output_processor = self.backend.new_chat_output_processor(
224
&mut request,
225
NewChatOutputProcessorOptions {
226
tool_call_parser: &self.tool_call_parser,
@@ -224,7 +228,17 @@ impl ChatRequestProcessor {
228
tool_strict_level: self.tool_strict_level,
229
},
230
)?;
227
- let text_request = self.prepare_text_request(request).await?;
231
+ let mut text_request = self.prepare_text_request(request).await?;
232
+
233
+ // Initialize the output processor from the final prompt token IDs and apply
234
+ // the output grammar to the request, if any.
235
+ let prompt_token_ids = text_processor.tokenize_in_place(&mut text_request)?;
236
+ let grammar = {
237
+ output_processor.initialize(prompt_token_ids)?;
238
+ output_processor.build_output_grammar()?
239
+ };
240
+ apply_output_grammar(&mut text_request, grammar)?;
241
+
242
Ok((text_request, output_processor))
243
}
244
}
@@ -342,7 +356,8 @@ impl ChatLlm {
356
357
/// Render, tokenize, and submit one chat request.
358
pub async fn chat(&self, request: ChatRequest) -> Result<ChatEventStream> {
345
- let (text_request, output_processor) = self.processor.prepare(request).await?;
359
+ let (text_request, output_processor) =
360
+ self.processor.prepare(request, self.text.request_processor()).await?;
361
let request_id = text_request.request_id.clone();
362
let decoded_stream = self.text.generate(text_request).await?.map_err(Error::from).boxed();
363
rust/src/chat/src/output/default/mod.rs
+125
-10
@@ -3,17 +3,17 @@
3
4
//! Default output processing pipeline.
5
6
-mod structural_tag;
6
mod unified;
7
8
use std::sync::Once;
9
10
use futures::StreamExt as _;
11
use tracing::info;
12
+use vllm_parser::output_grammar::{BuiltOutputGrammar, OutputGrammarContext};
13
use vllm_parser::unified::{CombinedParser, UnifiedParser};
14
use vllm_text::tokenizer::DynTokenizer;
15
+use xgrammar_structural_tag::ToolChoice;
16
16
-use self::structural_tag::apply_structural_tag_constraint;
17
use self::unified::unified_event_stream;
18
use super::structured::structured_chat_event_stream;
19
use crate::error::Result;
@@ -34,6 +34,18 @@ use crate::{Error, Result as ChatResult};
34
pub struct DefaultChatOutputProcessor {
35
parser: Box<dyn UnifiedParser>,
36
parallel_tool_calls: bool,
37
+ /// Request facts the initialized parser needs to build its output grammar.
38
+ /// Absent for the plain-text-only processor, which never builds one.
39
+ grammar_inputs: Option<GrammarInputs>,
40
+}
41
+
42
+/// Request-scoped inputs for [`UnifiedParser::build_output_grammar`], captured
43
+/// at construction because the chat request is consumed before the prompt is
44
+/// tokenized.
45
+struct GrammarInputs {
46
+ tools: Vec<ChatTool>,
47
+ tool_choice: ToolChoice,
48
+ tool_strict_level: ToolStrictLevel,
49
}
50
51
impl DefaultChatOutputProcessor {
@@ -42,7 +54,8 @@ impl DefaultChatOutputProcessor {
54
///
55
/// Parser resolution happens here so that request validation, prompt
56
/// rendering, and streaming all observe the same parser-adjusted
45
- /// request state.
57
+ /// request state. The parser is initialized and its output grammar built
58
+ /// later, once the final prompt token IDs are known.
59
pub fn new(
60
request: &mut ChatRequest,
61
model_id: &str,
@@ -74,12 +87,6 @@ impl DefaultChatOutputProcessor {
87
Box::new(CombinedParser::new(reasoning_parser, tool_parser)) as Box<dyn UnifiedParser>
88
};
89
77
- apply_structural_tag_constraint(
78
- request,
79
- parser.structural_tag_builder(),
80
- tool_strict_level,
81
- )?;
82
-
90
if parser.preserve_special_tokens() {
91
request.decode_options.skip_special_tokens = false;
92
}
@@ -87,6 +94,11 @@ impl DefaultChatOutputProcessor {
94
Ok(Self {
95
parser,
96
parallel_tool_calls: request.parallel_tool_calls(),
97
+ grammar_inputs: Some(GrammarInputs {
98
+ tools: request.tools().to_vec(),
99
+ tool_choice: request.tool_choice().into(),
100
+ tool_strict_level,
101
+ }),
102
})
103
}
104
@@ -99,6 +111,7 @@ impl DefaultChatOutputProcessor {
111
Self {
112
parser: Box::new(CombinedParser::plain_text_only()),
113
parallel_tool_calls: true,
114
+ grammar_inputs: None,
115
}
116
}
117
@@ -175,6 +188,30 @@ static REASONING_PARSER_LOG_ONCE: Once = Once::new();
188
static UNIFIED_PARSER_LOG_ONCE: Once = Once::new();
189
190
impl ChatOutputProcessor for DefaultChatOutputProcessor {
191
+ fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
192
+ self.parser.initialize(prompt_token_ids).map_err(|error| {
193
+ Error::OutputParserInitialization {
194
+ error: Box::new(error),
195
+ }
196
+ })
197
+ }
198
+
199
+ fn build_output_grammar(&self) -> Result<Option<BuiltOutputGrammar>> {
200
+ let Some(inputs) = &self.grammar_inputs else {
201
+ return Ok(None);
202
+ };
203
+ self.parser
204
+ .build_output_grammar(&OutputGrammarContext {
205
+ tools: &inputs.tools,
206
+ tool_choice: &inputs.tool_choice,
207
+ tool_strict_level: inputs.tool_strict_level,
208
+ parallel_tool_calls: self.parallel_tool_calls,
209
+ })
210
+ .map_err(|error| Error::OutputGrammar {
211
+ error: Box::new(error),
212
+ })
213
+ }
214
+
215
/// Transforms a raw generate-output token stream into structured chat
216
/// events through two sequential stages once text decoding has
217
/// already happened:
@@ -196,8 +233,9 @@ mod tests {
233
use vllm_tokenizer::test_utils::TestTokenizer;
234
235
use super::DefaultChatOutputProcessor;
236
+ use crate::output::ChatOutputProcessor;
237
use crate::parser::{ParserSelection, ToolStrictLevel};
200
- use crate::request::ChatRequest;
238
+ use crate::request::{ChatRequest, ChatTool, ChatToolChoice, ResolvedToolContext};
239
240
fn tokenizer() -> Arc<TestTokenizer> {
241
Arc::new(
@@ -207,6 +245,83 @@ mod tests {
245
)
246
}
247
248
+ #[test]
249
+ fn output_grammar_preserves_tool_strict_level() {
250
+ let build = |level, strict, choice| {
251
+ let tools = vec![ChatTool {
252
+ name: "search".to_string(),
253
+ description: None,
254
+ parameters: serde_json::json!({
255
+ "type": "object",
256
+ "properties": { "query": { "type": "string" } },
257
+ "required": ["query"]
258
+ }),
259
+ strict,
260
+ }];
261
+ let mut request = ChatRequest {
262
+ tool_context: ResolvedToolContext::new(&[], tools, Some(choice), true).unwrap(),
263
+ ..ChatRequest::for_test()
264
+ };
265
+ let mut processor = DefaultChatOutputProcessor::new(
266
+ &mut request,
267
+ "other-model",
268
+ tokenizer(),
269
+ &ParserSelection::Explicit("qwen3_coder".to_string()),
270
+ &ParserSelection::None,
271
+ level,
272
+ )
273
+ .unwrap();
274
+ processor.initialize(&[]).unwrap();
275
+ processor.build_output_grammar().unwrap()
276
+ };
277
+
278
+ assert!(build(ToolStrictLevel::Auto, None, ChatToolChoice::Auto).is_none());
279
+ let envelope = build(ToolStrictLevel::Function, None, ChatToolChoice::Auto).unwrap();
280
+ let parameters = build(ToolStrictLevel::Parameter, None, ChatToolChoice::Auto).unwrap();
281
+ let strict = build(ToolStrictLevel::Auto, Some(true), ChatToolChoice::Auto).unwrap();
282
+ assert_eq!(parameters, strict);
283
+ assert_ne!(envelope, parameters);
284
+ assert!(build(ToolStrictLevel::Parameter, None, ChatToolChoice::None).is_none());
285
+ }
286
+
287
+ #[test]
288
+ fn tool_grammar_honors_parallel_call_policy() {
289
+ for parallel in [false, true] {
290
+ let tools = vec![ChatTool {
291
+ name: "lookup".to_string(),
292
+ description: None,
293
+ parameters: serde_json::json!({"type": "object"}),
294
+ strict: Some(true),
295
+ }];
296
+ let mut request = ChatRequest {
297
+ tool_context: ResolvedToolContext::new(
298
+ &[],
299
+ tools,
300
+ Some(ChatToolChoice::Auto),
301
+ parallel,
302
+ )
303
+ .unwrap(),
304
+ ..ChatRequest::for_test()
305
+ };
306
+ let mut processor = DefaultChatOutputProcessor::new(
307
+ &mut request,
308
+ "other-model",
309
+ tokenizer(),
310
+ &ParserSelection::Explicit("qwen3_coder".to_string()),
311
+ &ParserSelection::None,
312
+ ToolStrictLevel::Auto,
313
+ )
314
+ .unwrap();
315
+ processor.initialize(&[]).unwrap();
316
+ let built = processor.build_output_grammar().unwrap().unwrap();
317
+ let tag =
318
+ serde_json::to_value(xgrammar_structural_tag::StructuralTag::new(built.format))
319
+ .unwrap();
320
+ assert_eq!(tag["format"]["type"], "triggered_tags");
321
+ assert_eq!(tag["format"]["stop_after_first"], !parallel);
322
+ }
323
+ }
324
+
325
#[test]
326
fn equal_explicit_gemma4_uses_unified_parser() {
327
let mut request = ChatRequest::for_test();
rust/src/chat/src/output/default/structural_tag.rs
deleted
-507
@@ -1,507 +0,0 @@
1
-// SPDX-License-Identifier: Apache-2.0
2
-// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-
4
-//! Applies xgrammar structural-tag constraints for strict tool calling.
5
-
6
-use thiserror_ext::AsReport;
7
-use vllm_engine_core_client::protocol::structured_outputs::{
8
- StructuredOutputBackend, StructuredOutputsParams,
9
-};
10
-use vllm_parser::tool::StructuralTagBuilder;
11
-use xgrammar_structural_tag::builders::StructuralTagOptions;
12
-use xgrammar_structural_tag::{
13
- FunctionDefinition, FunctionToolParam, ToolChoice as StructuralTagToolChoice, ToolParam,
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
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
- };
31
- let Some(tool_choice) = structural_tag_tool_choice(request, strict_level) else {
32
- return Ok(());
33
- };
34
-
35
- let tools = request
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()),
46
- strict: Some(strict),
47
- }))
48
- })
49
- .collect::<Vec<_>>();
50
-
51
- let structural_tag = build_structural_tag(
52
- builder,
53
- &tools,
54
- tool_choice,
55
- StructuralTagOptions::default()
56
- .with_reasoning(false)
57
- .with_parallel_tool_calls(request.parallel_tool_calls()),
58
- )
59
- .and_then(|tag| tag.to_json_string())
60
- .map_err(|error| Error::StructuralTag {
61
- message: error.to_report_string(),
62
- })?;
63
-
64
- // Overwrite any existing structured output settings with the structural tag constraint.
65
- request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
66
- backend: StructuredOutputBackend::Xgrammar,
67
- ..StructuredOutputsParams::structural_tag(structural_tag)
68
- });
69
-
70
- Ok(())
71
-}
72
-
73
-/// Resolve the tool choice used for [`xgrammar_structural_tag`] based on the request.
74
-///
75
-/// Returns `None` if no structural tag constraints should be applied.
76
-fn structural_tag_tool_choice(
77
- request: &ChatRequest,
78
- strict_level: ToolStrictLevel,
79
-) -> Option<StructuralTagToolChoice> {
80
- if request.tools().is_empty() {
81
- return None;
82
- }
83
-
84
- match request.tool_choice() {
85
- ChatToolChoice::None => None,
86
- // For `Auto`, apply the structural tag only when the server raises the floor or at
87
- // least one tool opts in with `strict: true`.
88
- ChatToolChoice::Auto => (strict_level >= ToolStrictLevel::Function
89
- || request.tools().iter().any(|tool| tool.strict == Some(true)))
90
- .then(StructuralTagToolChoice::auto),
91
-
92
- ChatToolChoice::Required => Some(StructuralTagToolChoice::required()),
93
- ChatToolChoice::Function { name } => Some(StructuralTagToolChoice::function(name.clone())),
94
- }
95
-}
96
-
97
-#[cfg(test)]
98
-mod tests {
99
- use serde_json::{Value, json};
100
- use vllm_engine_core_client::protocol::structured_outputs::{
101
- StructuredOutputBackend, StructuredOutputsParams,
102
- };
103
- use vllm_parser::tool::{Qwen3CoderToolParser, Tool, ToolParser};
104
-
105
- use super::*;
106
- use crate::request::{ChatMessage, ResolvedToolContext};
107
-
108
- fn chat_tool(name: &str, strict: Option<bool>) -> Tool {
109
- Tool {
110
- name: name.to_string(),
111
- description: None,
112
- parameters: json!({
113
- "type": "object",
114
- "properties": {
115
- "query": { "type": "string" }
116
- },
117
- "required": ["query"]
118
- }),
119
- strict,
120
- }
121
- }
122
-
123
- fn qwen3_coder_parser(tools: &[Tool]) -> Box<dyn ToolParser> {
124
- Qwen3CoderToolParser::create(tools).expect("Qwen3 Coder parser should build")
125
- }
126
-
127
- fn request(tool_choice: ChatToolChoice, tools: Vec<Tool>) -> ChatRequest {
128
- ChatRequest {
129
- tool_context: ResolvedToolContext::new(&[], tools, Some(tool_choice), true)
130
- .expect("tool context should resolve"),
131
- ..ChatRequest::for_test()
132
- }
133
- }
134
-
135
- fn structural_tag_value(request: &ChatRequest) -> Value {
136
- let params = request
137
- .sampling_params
138
- .structured_outputs
139
- .as_ref()
140
- .expect("structured outputs should be set");
141
- assert_eq!(params.backend, StructuredOutputBackend::Xgrammar);
142
- let structural_tag = params
143
- .constraint
144
- .as_structural_tag()
145
- .expect("structured output constraint should be structural_tag");
146
- serde_json::from_str(structural_tag).expect("structural_tag should be valid JSON")
147
- }
148
-
149
- fn structured_outputs(request: &ChatRequest) -> &StructuredOutputsParams {
150
- request
151
- .sampling_params
152
- .structured_outputs
153
- .as_ref()
154
- .expect("structured outputs should be set")
155
- }
156
-
157
- #[test]
158
- fn tool_grammar_honors_parallel_call_policy() {
159
- for parallel in [false, true] {
160
- let tools = vec![chat_tool("lookup", Some(true))];
161
- let mut request = ChatRequest {
162
- tool_context: ResolvedToolContext::new(
163
- &[],
164
- tools,
165
- Some(ChatToolChoice::Auto),
166
- parallel,
167
- )
168
- .unwrap(),
169
- ..ChatRequest::for_test()
170
- };
171
- let parser = qwen3_coder_parser(request.tools());
172
- apply_structural_tag_constraint(
173
- &mut request,
174
- parser.structural_tag_builder(),
175
- ToolStrictLevel::Auto,
176
- )
177
- .unwrap();
178
- let tag = structural_tag_value(&request);
179
- assert_eq!(tag["format"]["type"], "triggered_tags");
180
- assert_eq!(tag["format"]["stop_after_first"], !parallel);
181
- }
182
- }
183
-
184
- #[test]
185
- fn auto_strict_tool_choice_builds_structural_tag() {
186
- let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
187
- let parser = qwen3_coder_parser(request.tools());
188
-
189
- apply_structural_tag_constraint(
190
- &mut request,
191
- parser.structural_tag_builder(),
192
- ToolStrictLevel::Auto,
193
- )
194
- .expect("structural tag should build");
195
-
196
- let tag = structural_tag_value(&request);
197
- assert_eq!(tag["type"], "structural_tag");
198
- assert!(tag.to_string().contains("search"));
199
- }
200
-
201
- #[test]
202
- fn required_dynamic_tool_builds_structural_tag_from_effective_tools() {
203
- let messages = vec![ChatMessage::developer(
204
- "",
205
- Some(vec![chat_tool("lookup", None)]),
206
- )];
207
- let tool_context =
208
- ResolvedToolContext::new(&messages, Vec::new(), Some(ChatToolChoice::Required), true)
209
- .expect("dynamic tool context should resolve");
210
- let mut request = ChatRequest {
211
- messages,
212
- tool_context,
213
- ..ChatRequest::for_test()
214
- };
215
- let parser = qwen3_coder_parser(request.tools());
216
-
217
- apply_structural_tag_constraint(
218
- &mut request,
219
- parser.structural_tag_builder(),
220
- ToolStrictLevel::Auto,
221
- )
222
- .expect("structural tag should build");
223
-
224
- let tag = structural_tag_value(&request);
225
- assert!(tag.to_string().contains("lookup"));
226
- }
227
-
228
- #[test]
229
- fn required_initial_and_dynamic_tools_build_one_structural_tag() {
230
- let messages = vec![ChatMessage::developer(
231
- "",
232
- Some(vec![chat_tool("lookup", None)]),
233
- )];
234
- let tool_context = ResolvedToolContext::new(
235
- &messages,
236
- vec![chat_tool("search", None)],
237
- Some(ChatToolChoice::Required),
238
- true,
239
- )
240
- .expect("tool context should resolve");
241
- let mut request = ChatRequest {
242
- messages,
243
- tool_context,
244
- ..ChatRequest::for_test()
245
- };
246
- let parser = qwen3_coder_parser(request.tools());
247
-
248
- apply_structural_tag_constraint(
249
- &mut request,
250
- parser.structural_tag_builder(),
251
- ToolStrictLevel::Auto,
252
- )
253
- .expect("structural tag should build");
254
-
255
- let tag = structural_tag_value(&request).to_string();
256
- assert!(tag.contains("search"));
257
- assert!(tag.contains("lookup"));
258
- }
259
-
260
- #[test]
261
- fn auto_non_strict_tool_choice_skips_structural_tag() {
262
- let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", None)]);
263
- let parser = qwen3_coder_parser(request.tools());
264
-
265
- apply_structural_tag_constraint(
266
- &mut request,
267
- parser.structural_tag_builder(),
268
- ToolStrictLevel::Auto,
269
- )
270
- .expect("structural tag decision should succeed");
271
-
272
- assert!(request.sampling_params.structured_outputs.is_none());
273
- }
274
-
275
- #[test]
276
- fn auto_strict_tool_choice_overwrites_existing_json_guidance() {
277
- let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
278
- request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
279
- backend: StructuredOutputBackend::Xgrammar,
280
- ..StructuredOutputsParams::json(json!({"type": "object"}))
281
- });
282
- let parser = qwen3_coder_parser(request.tools());
283
-
284
- apply_structural_tag_constraint(
285
- &mut request,
286
- parser.structural_tag_builder(),
287
- ToolStrictLevel::Auto,
288
- )
289
- .expect("structural tag should build");
290
-
291
- let params = structured_outputs(&request);
292
- assert!(params.constraint.is_structural_tag());
293
- let tag = structural_tag_value(&request);
294
- assert_eq!(tag["type"], "structural_tag");
295
- assert!(tag.to_string().contains("search"));
296
- }
297
-
298
- #[test]
299
- fn required_tool_choice_builds_structural_tag_without_strict_tools() {
300
- let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
301
- let parser = qwen3_coder_parser(request.tools());
302
-
303
- apply_structural_tag_constraint(
304
- &mut request,
305
- parser.structural_tag_builder(),
306
- ToolStrictLevel::Auto,
307
- )
308
- .expect("structural tag should build");
309
-
310
- let tag = structural_tag_value(&request);
311
- assert_eq!(tag["type"], "structural_tag");
312
- assert!(tag.to_string().contains("search"));
313
- }
314
-
315
- #[test]
316
- fn required_tool_choice_overwrites_existing_json_object_guidance() {
317
- let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
318
- request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
319
- backend: StructuredOutputBackend::Xgrammar,
320
- ..StructuredOutputsParams::json_object()
321
- });
322
- let parser = qwen3_coder_parser(request.tools());
323
-
324
- apply_structural_tag_constraint(
325
- &mut request,
326
- parser.structural_tag_builder(),
327
- ToolStrictLevel::Auto,
328
- )
329
- .expect("structural tag should build");
330
-
331
- let params = structured_outputs(&request);
332
- assert!(params.constraint.is_structural_tag());
333
- let tag = structural_tag_value(&request);
334
- assert_eq!(tag["type"], "structural_tag");
335
- assert!(tag.to_string().contains("search"));
336
- }
337
-
338
- #[test]
339
- fn named_tool_choice_builds_structural_tag_for_named_tool_only() {
340
- let mut request = request(
341
- ChatToolChoice::Function {
342
- name: "lookup".to_string(),
343
- },
344
- vec![chat_tool("search", None), chat_tool("lookup", None)],
345
- );
346
- let parser = qwen3_coder_parser(request.tools());
347
-
348
- apply_structural_tag_constraint(
349
- &mut request,
350
- parser.structural_tag_builder(),
351
- ToolStrictLevel::Auto,
352
- )
353
- .expect("structural tag should build");
354
-
355
- let tag = structural_tag_value(&request).to_string();
356
- assert!(tag.contains("lookup"));
357
- assert!(!tag.contains("search"));
358
- }
359
-
360
- #[test]
361
- fn none_tool_choice_skips_structural_tag() {
362
- let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
363
- let parser = qwen3_coder_parser(request.tools());
364
-
365
- apply_structural_tag_constraint(
366
- &mut request,
367
- parser.structural_tag_builder(),
368
- ToolStrictLevel::Auto,
369
- )
370
- .expect("structural tag decision should succeed");
371
-
372
- assert!(request.sampling_params.structured_outputs.is_none());
373
- }
374
-
375
- #[test]
376
- fn none_tool_choice_preserves_existing_json_object_guidance() {
377
- let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
378
- request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
379
- backend: StructuredOutputBackend::Xgrammar,
380
- ..StructuredOutputsParams::json_object()
381
- });
382
- let parser = qwen3_coder_parser(request.tools());
383
-
384
- apply_structural_tag_constraint(
385
- &mut request,
386
- parser.structural_tag_builder(),
387
- ToolStrictLevel::Auto,
388
- )
389
- .expect("structural tag decision should succeed");
390
-
391
- let params = structured_outputs(&request);
392
- assert!(params.constraint.is_json_object());
393
- }
394
-
395
- fn build(tool_choice: ChatToolChoice, tools: Vec<Tool>, level: ToolStrictLevel) -> ChatRequest {
396
- let mut request = request(tool_choice, tools);
397
- let parser = qwen3_coder_parser(request.tools());
398
-
399
- apply_structural_tag_constraint(&mut request, parser.structural_tag_builder(), level)
400
- .expect("structural tag decision should succeed");
401
- request
402
- }
403
-
404
- fn tag_string(request: &ChatRequest) -> String {
405
- structural_tag_value(request).to_string()
406
- }
407
-
408
- #[test]
409
- fn function_level_constrains_auto_without_strict_tools() {
410
- let request = build(
411
- ChatToolChoice::Auto,
412
- vec![chat_tool("search", None)],
413
- ToolStrictLevel::Function,
414
- );
415
-
416
- let tag = structural_tag_value(&request);
417
- assert_eq!(tag["type"], "structural_tag");
418
- assert!(tag.to_string().contains("search"));
419
- }
420
-
421
- #[test]
422
- fn absent_strict_is_non_strict() {
423
- let unset = build(
424
- ChatToolChoice::Required,
425
- vec![chat_tool("search", None)],
426
- ToolStrictLevel::Auto,
427
- );
428
- let non_strict = build(
429
- ChatToolChoice::Required,
430
- vec![chat_tool("search", Some(false))],
431
- ToolStrictLevel::Auto,
432
- );
433
- let strict = build(
434
- ChatToolChoice::Required,
435
- vec![chat_tool("search", Some(true))],
436
- ToolStrictLevel::Auto,
437
- );
438
-
439
- assert_eq!(tag_string(&unset), tag_string(&non_strict));
440
- assert_ne!(tag_string(&unset), tag_string(&strict));
441
- }
442
-
443
- #[test]
444
- fn strict_tool_does_not_pin_its_neighbour() {
445
- let mixed = build(
446
- ChatToolChoice::Auto,
447
- vec![chat_tool("weather", Some(true)), chat_tool("search", None)],
448
- ToolStrictLevel::Auto,
449
- );
450
- let explicit = build(
451
- ChatToolChoice::Auto,
452
- vec![
453
- chat_tool("weather", Some(true)),
454
- chat_tool("search", Some(false)),
455
- ],
456
- ToolStrictLevel::Auto,
457
- );
458
-
459
- assert_eq!(tag_string(&mixed), tag_string(&explicit));
460
- }
461
-
462
- #[test]
463
- fn parameter_level_pins_argument_schemas() {
464
- let pinned = build(
465
- ChatToolChoice::Auto,
466
- vec![chat_tool("search", None)],
467
- ToolStrictLevel::Parameter,
468
- );
469
- let strict = build(
470
- ChatToolChoice::Auto,
471
- vec![chat_tool("search", Some(true))],
472
- ToolStrictLevel::Auto,
473
- );
474
- let envelope_only = build(
475
- ChatToolChoice::Auto,
476
- vec![chat_tool("search", None)],
477
- ToolStrictLevel::Function,
478
- );
479
-
480
- assert_eq!(tag_string(&pinned), tag_string(&strict));
481
- assert_ne!(tag_string(&pinned), tag_string(&envelope_only));
482
- }
483
-
484
- #[test]
485
- fn tool_strict_level_parses_case_insensitively() {
486
- assert_eq!("AUTO".parse::<ToolStrictLevel>(), Ok(ToolStrictLevel::Auto));
487
- assert_eq!(
488
- "PARAMETER".parse::<ToolStrictLevel>(),
489
- Ok(ToolStrictLevel::Parameter)
490
- );
491
- assert_eq!(
492
- "function".parse::<ToolStrictLevel>(),
493
- Ok(ToolStrictLevel::Function)
494
- );
495
- assert_eq!(ToolStrictLevel::default(), ToolStrictLevel::Auto);
496
- assert_eq!(
497
- serde_json::to_value(ToolStrictLevel::Auto).unwrap(),
498
- json!("auto")
499
- );
500
- assert_eq!(
501
- serde_json::from_value::<ToolStrictLevel>(json!("auto")).unwrap(),
502
- ToolStrictLevel::Auto
503
- );
504
- assert!("off".parse::<ToolStrictLevel>().is_err());
505
- assert!("strict".parse::<ToolStrictLevel>().is_err());
506
- }
507
-}
rust/src/chat/src/output/default/unified.rs
-20
@@ -48,25 +48,6 @@ impl UnifiedParserState {
48
}
49
}
50
51
- /// Initialize parser state once prompt token IDs are available.
52
- fn initialize(&mut self, prompt_token_ids: &[u32]) {
53
- if self.parser_failed {
54
- return;
55
- }
56
-
57
- match self.parser.initialize(prompt_token_ids) {
58
- Ok(()) => {}
59
- Err(error) => {
60
- warn!(
61
- error = %error.as_report(),
62
- "failed to initialize unified parser; falling back to plain text deltas"
63
- );
64
- self.parser_failed = true;
65
- self.open_call_index = None;
66
- }
67
- }
68
- }
69
-
51
/// Convert one decoded text delta into zero or more parsed assistant events.
52
fn process_delta(&mut self, delta: DecodedText) -> Result<Vec<AssistantEvent>> {
53
if self.parser_failed {
@@ -262,7 +243,6 @@ pub(crate) async fn unified_event_stream(
243
prompt_token_ids,
244
prompt_logprobs,
245
} => {
265
- state.initialize(&prompt_token_ids);
246
y.yield_ok(AssistantEvent::Start {
247
prompt_token_ids,
248
prompt_logprobs,
rust/src/chat/src/output/mod.rs
+20
@@ -7,6 +7,7 @@ use std::sync::Arc;
7
use futures::Stream;
8
use trait_set::trait_set;
9
use uuid::Uuid;
10
+use vllm_parser::output_grammar::BuiltOutputGrammar;
11
use vllm_text::output::{DecodedLogprobs, DecodedPromptLogprobs, DecodedTextEvent};
12
13
use crate::FinishReason;
@@ -15,11 +16,13 @@ use crate::event::{AssistantBlockKind, ChatEvent, ChatTokenUsage};
16
17
mod default;
18
mod harmony;
19
+mod structural_tag;
20
mod structured;
21
22
pub use default::DefaultChatOutputProcessor;
23
pub use harmony::HarmonyChatOutputProcessor;
24
pub(crate) use harmony::validate_harmony_parser_overrides;
25
+pub(crate) use structural_tag::apply_output_grammar;
26
27
/// Internal assistant event before final assembly.
28
///
@@ -65,7 +68,24 @@ pub type DynChatEventStream = Pin<Box<dyn Stream<Item = Result<ChatEvent>> + Sen
68
69
/// Request-scoped output processor from decoded text events into structured
70
/// chat events.
71
+///
72
+/// A processor is created before the prompt is rendered, so that parser
73
+/// resolution can adjust the request, and then goes through two more phases
74
+/// once the final prompt token IDs are known: [`Self::initialize`] and
75
+/// [`Self::build_output_grammar`], in that order, both before
76
+/// [`Self::process`].
77
pub trait ChatOutputProcessor: Send {
78
+ /// Initialize request-scoped parser state from the final prompt token IDs.
79
+ fn initialize(&mut self, _prompt_token_ids: &[u32]) -> Result<()> {
80
+ Ok(())
81
+ }
82
+
83
+ /// Build the structured output grammar implied by the initialized parser
84
+ /// state, or `None` when this request needs no parser-owned grammar.
85
+ fn build_output_grammar(&self) -> Result<Option<BuiltOutputGrammar>> {
86
+ Ok(None)
87
+ }
88
+
89
/// Consume decoded text stream and return the structured chat-event stream.
90
fn process(self: Box<Self>, decoded: DynDecodedTextEventStream) -> Result<DynChatEventStream>;
91
}
rust/src/chat/src/output/structural_tag.rs
new
+73
@@ -0,0 +1,73 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Applies parser-built xgrammar structural-tag constraints.
5
+
6
+use vllm_engine_core_client::protocol::structured_outputs::{
7
+ StructuredOutputBackend, StructuredOutputsParams,
8
+};
9
+use vllm_parser::output_grammar::{BuiltOutputGrammar, GrammarCoverage};
10
+use vllm_text::TextRequest;
11
+use xgrammar_structural_tag::StructuralTag;
12
+
13
+use crate::{Error, Result};
14
+
15
+/// Apply one parser-built output grammar to the prepared text request.
16
+///
17
+/// A parser-owned grammar replaces any structured output constraint the user
18
+/// supplied, matching the Python frontend. `None` leaves the request untouched.
19
+pub(crate) fn apply_output_grammar(
20
+ request: &mut TextRequest,
21
+ built: Option<BuiltOutputGrammar>,
22
+) -> Result<()> {
23
+ let Some(built) = built else {
24
+ return Ok(());
25
+ };
26
+ let structural_tag = StructuralTag::new(built.format).to_json_string().map_err(|error| {
27
+ Error::OutputGrammar {
28
+ error: Box::new(error),
29
+ }
30
+ })?;
31
+
32
+ // Overwrite any existing structured output settings with the structural tag constraint.
33
+ request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
34
+ backend: StructuredOutputBackend::Xgrammar,
35
+ ..StructuredOutputsParams::structural_tag(structural_tag)
36
+ });
37
+ request.reasoning_ended = match built.coverage {
38
+ GrammarCoverage::FromTokenZero => Some(true),
39
+ GrammarCoverage::FinalOutputOnly => None,
40
+ };
41
+
42
+ Ok(())
43
+}
44
+
45
+#[cfg(test)]
46
+mod tests {
47
+ use serde_json::json;
48
+ use vllm_engine_core_client::protocol::structured_outputs::{
49
+ StructuredOutputBackend, StructuredOutputsParams,
50
+ };
51
+ use xgrammar_structural_tag::format::Format;
52
+
53
+ use super::*;
54
+
55
+ #[test]
56
+ fn output_grammar_overwrites_answer_constraint() {
57
+ let mut request = TextRequest::for_test();
58
+ request.sampling_params.structured_outputs =
59
+ Some(StructuredOutputsParams::json(json!({ "type": "object" })));
60
+
61
+ let built = BuiltOutputGrammar::from_token_zero(Format::const_string("answer"));
62
+ apply_output_grammar(&mut request, Some(built)).unwrap();
63
+
64
+ assert_eq!(request.reasoning_ended, Some(true));
65
+ let params = request.sampling_params.structured_outputs.unwrap();
66
+ assert_eq!(params.backend, StructuredOutputBackend::Xgrammar);
67
+ let serialized = params.constraint.as_structural_tag().unwrap();
68
+ let value: serde_json::Value = serde_json::from_str(serialized).unwrap();
69
+ assert_eq!(value["type"], "structural_tag");
70
+ assert_eq!(value["format"]["type"], "const_string");
71
+ assert_eq!(value["format"]["value"], "answer");
72
+ }
73
+}
rust/src/chat/src/parser/mod.rs
+2
-60
@@ -12,6 +12,8 @@ use std::str::FromStr;
12
13
use serde_with::{DeserializeFromStr, SerializeDisplay};
14
15
+pub use vllm_parser::output_grammar::ToolStrictLevel;
16
+
17
use self::reasoning::ReasoningParserFactory;
18
use self::tool::ToolParserFactory;
19
use self::unified::UnifiedParserFactory;
@@ -57,66 +59,6 @@ impl ParserSelection {
59
}
60
}
61
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
-
62
/// Validate explicit parser override names without starting request processing.
63
pub fn validate_parser_overrides(
64
tool_call_parser: &ParserSelection,
rust/src/chat/src/request.rs
+11
@@ -435,6 +435,17 @@ pub enum ChatToolChoice {
435
},
436
}
437
438
+impl From<&ChatToolChoice> for xgrammar_structural_tag::ToolChoice {
439
+ fn from(tool_choice: &ChatToolChoice) -> Self {
440
+ match tool_choice {
441
+ ChatToolChoice::None => Self::none(),
442
+ ChatToolChoice::Auto => Self::auto(),
443
+ ChatToolChoice::Required => Self::required(),
444
+ ChatToolChoice::Function { name } => Self::function(name.clone()),
445
+ }
446
+ }
447
+}
448
+
449
/// Resolved tool state shared by rendering, output parsing, and constraints.
450
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
451
pub struct ResolvedToolContext {
rust/src/chat/tests/roundtrip.rs
+2
-1
@@ -728,7 +728,7 @@ async fn parse_completion(
728
};
729
730
let mut request = base_request.clone();
731
- let processor = backends.chat_backend.new_chat_output_processor(
731
+ let mut processor = backends.chat_backend.new_chat_output_processor(
732
&mut request,
733
NewChatOutputProcessorOptions {
734
tool_strict_level: vllm_chat::ToolStrictLevel::Auto,
@@ -736,6 +736,7 @@ async fn parse_completion(
736
reasoning_parser: &case.reasoning_parser,
737
},
738
)?;
739
+ processor.initialize(&prompt_token_ids)?;
740
741
let decoded = decoded_completion_stream(
742
tokenizer.as_ref(),
rust/src/engine-core-client/src/protocol/request.rs
+1
-1
@@ -61,7 +61,7 @@ impl EngineCoreRequestType {
61
///
62
/// Original Python construction point:
63
/// <https://github.com/vllm-project/vllm/blob/cec2ec11760f9f3beabd4c90451936078bf91533/vllm/entrypoints/openai/chat_completion/serving.py#L367-L369>
64
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
65
pub struct ReasoningParserKwargs {
66
/// Effective kwargs visible to the chat template for this request.
67
pub chat_template_kwargs: HashMap<String, serde_json::Value>,
rust/src/llm/examples/external_engine_smoke.rs
+1
@@ -62,6 +62,7 @@ fn build_request(request_id: String, max_tokens: u32) -> GenerateRequest {
62
session_id: None,
63
kv_hints: None,
64
reasoning_parser_kwargs: None,
65
+ reasoning_ended: None,
66
lora_request: None,
67
}
68
}
rust/src/llm/src/request.rs
+8
-3
@@ -54,6 +54,11 @@ pub struct GenerateRequest {
54
/// Optional reasoning-parser kwargs forwarded to engine-side structured
55
/// output logic.
56
pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
57
+ /// Optional engine reasoning-gate override.
58
+ ///
59
+ /// `Some(true)` means the submitted grammar covers reasoning from the first
60
+ /// generated token.
61
+ pub reasoning_ended: Option<bool>,
62
/// Optional LoRA adapter request applied to this generation.
63
pub lora_request: Option<LoraRequest>,
64
}
@@ -84,6 +89,7 @@ impl GenerateRequest {
89
session_id,
90
kv_hints,
91
reasoning_parser_kwargs,
92
+ reasoning_ended,
93
lora_request,
94
} = self;
95
@@ -115,9 +121,7 @@ impl GenerateRequest {
121
session_id,
122
kv_hints,
123
external_req_id: Some(external_request_id),
118
- // Rust parser doesn't expose this information, leave it unset and let the
119
- // reasoning logic in engine-sided structured output manager handle it.
120
- reasoning_ended: None,
124
+ reasoning_ended,
125
reasoning_parser_kwargs,
126
abort_immediately: false,
127
},
@@ -180,6 +184,7 @@ mod tests {
184
)]
185
.into(),
186
}),
187
+ reasoning_ended: None,
188
lora_request: None,
189
}
190
}
rust/src/llm/tests/generate.rs
+1
@@ -169,6 +169,7 @@ fn sample_generate_request(request_id: &str, max_tokens: u32) -> GenerateRequest
169
session_id: None,
170
kv_hints: None,
171
reasoning_parser_kwargs: None,
172
+ reasoning_ended: None,
173
lora_request: None,
174
}
175
}
rust/src/parser/Cargo.toml
+1
@@ -11,6 +11,7 @@ test-util = []
11
easy-ext.workspace = true
12
serde.workspace = true
13
serde_json.workspace = true
14
+serde_with.workspace = true
15
thiserror.workspace = true
16
thiserror-ext.workspace = true
17
vllm-tokenizer.workspace = true
rust/src/parser/benches/utils/adapter.rs
+1
-7
@@ -3,9 +3,7 @@
3
4
use std::sync::Arc;
5
6
-use vllm_parser::tool::{
7
- Result, StructuralTagBuilder, Tool, ToolParser, ToolParserError, ToolParserOutput,
8
-};
6
+use vllm_parser::tool::{Result, Tool, ToolParser, ToolParserError, ToolParserOutput};
7
use vllm_parser::unified::{
8
UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput,
9
};
@@ -89,10 +87,6 @@ impl<T: UnifiedParser> ToolParser for UnifiedToolParserAdapter<T> {
87
self.inner.preserve_special_tokens()
88
}
89
92
- fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
93
- self.inner.structural_tag_builder()
94
- }
95
-
90
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
91
self.inner.tool_call_id(tool_index)
92
}
rust/src/parser/src/lib.rs
+1
@@ -3,6 +3,7 @@
3
4
//! Streaming parsers for chat completions.
5
6
+pub mod output_grammar;
7
pub mod reasoning;
8
pub mod tool;
9
pub mod unified;
rust/src/parser/src/output_grammar.rs
new
+362
@@ -0,0 +1,362 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Output grammar construction shared by reasoning and tool parsers.
5
+
6
+use std::{fmt, str::FromStr};
7
+
8
+use serde_with::{DeserializeFromStr, SerializeDisplay};
9
+use thiserror::Error;
10
+use xgrammar_structural_tag::builders::{StructuralTagBuilder, StructuralTagOptions};
11
+use xgrammar_structural_tag::format::Format;
12
+use xgrammar_structural_tag::tool::ToolChoiceValue;
13
+use xgrammar_structural_tag::{
14
+ FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
15
+};
16
+
17
+use crate::tool::Tool;
18
+
19
+/// Result alias for output grammar construction.
20
+pub type Result<T> = std::result::Result<T, OutputGrammarError>;
21
+
22
+/// How much of the generated stream an output grammar covers.
23
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24
+pub enum GrammarCoverage {
25
+ /// The grammar covers reasoning and advances from the first generated token.
26
+ FromTokenZero,
27
+ /// The grammar covers only output emitted after reasoning ends.
28
+ FinalOutputOnly,
29
+}
30
+
31
+/// One parser-built output grammar and its generated-stream coverage.
32
+#[derive(Debug, Clone, PartialEq)]
33
+pub struct BuiltOutputGrammar {
34
+ /// Typed XGrammar structural-tag format.
35
+ pub format: Format,
36
+ /// Portion of the generated stream covered by `format`.
37
+ pub coverage: GrammarCoverage,
38
+}
39
+
40
+impl BuiltOutputGrammar {
41
+ /// A grammar that covers reasoning and everything after it.
42
+ pub fn from_token_zero(format: Format) -> Self {
43
+ Self {
44
+ format,
45
+ coverage: GrammarCoverage::FromTokenZero,
46
+ }
47
+ }
48
+
49
+ /// A grammar that covers only the output emitted after reasoning ends.
50
+ pub fn final_output_only(format: Format) -> Self {
51
+ Self {
52
+ format,
53
+ coverage: GrammarCoverage::FinalOutputOnly,
54
+ }
55
+ }
56
+}
57
+
58
+/// Server-side floor for tool-call structural tags (`--tool-strict-level`).
59
+#[derive(
60
+ Debug,
61
+ Clone,
62
+ Copy,
63
+ PartialEq,
64
+ Eq,
65
+ PartialOrd,
66
+ Ord,
67
+ Default,
68
+ DeserializeFromStr,
69
+ SerializeDisplay,
70
+)]
71
+pub enum ToolStrictLevel {
72
+ /// Derive tool constraints from the request's tool choice and per-tool strictness.
73
+ #[default]
74
+ Auto,
75
+ /// Constrain the tool-call envelope for every request with tools.
76
+ Function,
77
+ /// Additionally pin argument schemas, as if every tool were `strict: true`.
78
+ Parameter,
79
+}
80
+
81
+impl ToolStrictLevel {
82
+ pub const AUTO_LITERAL: &str = "auto";
83
+ pub const FUNCTION_LITERAL: &str = "function";
84
+ pub const PARAMETER_LITERAL: &str = "parameter";
85
+}
86
+
87
+impl FromStr for ToolStrictLevel {
88
+ type Err = String;
89
+
90
+ fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
91
+ if value.eq_ignore_ascii_case(Self::AUTO_LITERAL) {
92
+ Ok(Self::Auto)
93
+ } else if value.eq_ignore_ascii_case(Self::FUNCTION_LITERAL) {
94
+ Ok(Self::Function)
95
+ } else if value.eq_ignore_ascii_case(Self::PARAMETER_LITERAL) {
96
+ Ok(Self::Parameter)
97
+ } else {
98
+ Err(format!(
99
+ "unknown tool strict level {value:?}; expected one of {}, {}, {}",
100
+ Self::AUTO_LITERAL,
101
+ Self::FUNCTION_LITERAL,
102
+ Self::PARAMETER_LITERAL
103
+ ))
104
+ }
105
+ }
106
+}
107
+
108
+impl fmt::Display for ToolStrictLevel {
109
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110
+ f.write_str(match self {
111
+ Self::Auto => Self::AUTO_LITERAL,
112
+ Self::Function => Self::FUNCTION_LITERAL,
113
+ Self::Parameter => Self::PARAMETER_LITERAL,
114
+ })
115
+ }
116
+}
117
+
118
+/// Request facts available while an initialized parser builds an output grammar.
119
+pub struct OutputGrammarContext<'a> {
120
+ /// Effective tools available for this generation.
121
+ pub tools: &'a [Tool],
122
+ /// Effective tool-choice policy.
123
+ pub tool_choice: &'a ToolChoice,
124
+ /// Server-side floor for tool-call constraints.
125
+ pub tool_strict_level: ToolStrictLevel,
126
+ /// Whether the request permits more than one tool call.
127
+ pub parallel_tool_calls: bool,
128
+}
129
+
130
+/// Errors produced while building an output grammar.
131
+#[derive(Debug, Error)]
132
+pub enum OutputGrammarError {
133
+ /// A model-specific structural-tag builder rejected its inputs.
134
+ #[error("failed to build output grammar")]
135
+ Build(#[from] xgrammar_structural_tag::Error),
136
+}
137
+
138
+/// Build the visible (post-reasoning) language from a crate structural-tag
139
+/// builder, or `None` when the request does not ask for strict tool calling.
140
+///
141
+/// This is the pre-existing strict-tool-calling grammar: the builder runs with
142
+/// `reasoning = false`, so the result describes only what follows the reasoning
143
+/// phase. It backs the default `ToolParser::build_visible_format` and the
144
+/// native unified parsers that have not yet grown their own builder.
145
+pub(crate) fn visible_format_from_builder(
146
+ builder: Option<&dyn StructuralTagBuilder>,
147
+ ctx: &OutputGrammarContext<'_>,
148
+) -> Result<Option<Format>> {
149
+ let Some(builder) = builder else {
150
+ return Ok(None);
151
+ };
152
+ if !tool_grammar_applies(ctx) {
153
+ return Ok(None);
154
+ }
155
+
156
+ let structural_tag = build_structural_tag(
157
+ builder,
158
+ &tool_params(ctx.tools, ctx.tool_strict_level),
159
+ ctx.tool_choice.clone(),
160
+ StructuralTagOptions::default()
161
+ .with_reasoning(false)
162
+ .with_parallel_tool_calls(ctx.parallel_tool_calls),
163
+ )?;
164
+
165
+ Ok(Some(structural_tag.format))
166
+}
167
+
168
+fn tool_params(tools: &[Tool], strict_level: ToolStrictLevel) -> Vec<ToolParam> {
169
+ tools
170
+ .iter()
171
+ .map(|tool| {
172
+ ToolParam::Function(FunctionToolParam::new(FunctionDefinition {
173
+ name: tool.name.clone(),
174
+ description: tool.description.clone(),
175
+ parameters: Some(tool.parameters.clone()),
176
+ strict: Some(
177
+ strict_level >= ToolStrictLevel::Parameter || tool.strict == Some(true),
178
+ ),
179
+ }))
180
+ })
181
+ .collect()
182
+}
183
+
184
+/// Whether the request asks for a tool grammar at all.
185
+fn tool_grammar_applies(ctx: &OutputGrammarContext<'_>) -> bool {
186
+ if ctx.tools.is_empty() {
187
+ return false;
188
+ }
189
+
190
+ match ctx.tool_choice {
191
+ ToolChoice::Value(ToolChoiceValue::None) => false,
192
+ ToolChoice::Value(ToolChoiceValue::Auto) => {
193
+ ctx.tool_strict_level >= ToolStrictLevel::Function
194
+ || ctx.tools.iter().any(|tool| tool.strict == Some(true))
195
+ }
196
+ ToolChoice::Value(ToolChoiceValue::Required)
197
+ | ToolChoice::NamedFunction(_)
198
+ | ToolChoice::AllowedTools(_)
199
+ | ToolChoice::FlatAllowedTools(_)
200
+ | ToolChoice::Builtin(_) => true,
201
+ }
202
+}
203
+
204
+#[cfg(test)]
205
+mod tests {
206
+ use serde_json::json;
207
+ use xgrammar_structural_tag::builders::Qwen3Builder;
208
+
209
+ use super::*;
210
+
211
+ fn tool(name: &str, strict: Option<bool>) -> Tool {
212
+ Tool {
213
+ name: name.to_string(),
214
+ description: None,
215
+ parameters: json!({
216
+ "type": "object",
217
+ "properties": { "query": { "type": "string" } },
218
+ "required": ["query"]
219
+ }),
220
+ strict,
221
+ }
222
+ }
223
+
224
+ fn build(tools: &[Tool], tool_choice: &ToolChoice) -> Option<Format> {
225
+ visible_format_from_builder(
226
+ Some(&Qwen3Builder),
227
+ &OutputGrammarContext {
228
+ tools,
229
+ tool_choice,
230
+ tool_strict_level: ToolStrictLevel::Auto,
231
+ parallel_tool_calls: true,
232
+ },
233
+ )
234
+ .unwrap()
235
+ }
236
+
237
+ #[test]
238
+ fn tool_grammar_requires_tools_and_a_constraining_choice() {
239
+ let non_strict = [tool("search", None)];
240
+ assert!(build(&non_strict, &ToolChoice::none()).is_none());
241
+ assert!(build(&non_strict, &ToolChoice::auto()).is_none());
242
+ assert!(build(&non_strict, &ToolChoice::required()).is_some());
243
+ assert!(build(&non_strict, &ToolChoice::function("search")).is_some());
244
+
245
+ // `auto` only constrains once at least one tool is strict.
246
+ let strict = [tool("search", Some(true)), tool("lookup", None)];
247
+ assert!(build(&strict, &ToolChoice::auto()).is_some());
248
+ }
249
+
250
+ fn build_at_level(tool_choice: ToolChoice, tools: Vec<Tool>, level: ToolStrictLevel) -> Format {
251
+ visible_format_from_builder(
252
+ Some(&Qwen3Builder),
253
+ &OutputGrammarContext {
254
+ tools: &tools,
255
+ tool_choice: &tool_choice,
256
+ tool_strict_level: level,
257
+ parallel_tool_calls: true,
258
+ },
259
+ )
260
+ .unwrap()
261
+ .unwrap()
262
+ }
263
+
264
+ fn tag_string(format: &Format) -> String {
265
+ serde_json::to_string(format).unwrap()
266
+ }
267
+
268
+ #[test]
269
+ fn function_level_constrains_auto_without_strict_tools() {
270
+ let request = build_at_level(
271
+ ToolChoice::auto(),
272
+ vec![tool("search", None)],
273
+ ToolStrictLevel::Function,
274
+ );
275
+
276
+ assert!(tag_string(&request).contains("search"));
277
+ }
278
+
279
+ #[test]
280
+ fn absent_strict_is_non_strict() {
281
+ let unset = build_at_level(
282
+ ToolChoice::required(),
283
+ vec![tool("search", None)],
284
+ ToolStrictLevel::Auto,
285
+ );
286
+ let non_strict = build_at_level(
287
+ ToolChoice::required(),
288
+ vec![tool("search", Some(false))],
289
+ ToolStrictLevel::Auto,
290
+ );
291
+ let strict = build_at_level(
292
+ ToolChoice::required(),
293
+ vec![tool("search", Some(true))],
294
+ ToolStrictLevel::Auto,
295
+ );
296
+
297
+ assert_eq!(tag_string(&unset), tag_string(&non_strict));
298
+ assert_ne!(tag_string(&unset), tag_string(&strict));
299
+ }
300
+
301
+ #[test]
302
+ fn strict_tool_does_not_pin_its_neighbour() {
303
+ let mixed = build_at_level(
304
+ ToolChoice::auto(),
305
+ vec![tool("weather", Some(true)), tool("search", None)],
306
+ ToolStrictLevel::Auto,
307
+ );
308
+ let explicit = build_at_level(
309
+ ToolChoice::auto(),
310
+ vec![tool("weather", Some(true)), tool("search", Some(false))],
311
+ ToolStrictLevel::Auto,
312
+ );
313
+
314
+ assert_eq!(tag_string(&mixed), tag_string(&explicit));
315
+ }
316
+
317
+ #[test]
318
+ fn parameter_level_pins_argument_schemas() {
319
+ let pinned = build_at_level(
320
+ ToolChoice::auto(),
321
+ vec![tool("search", None)],
322
+ ToolStrictLevel::Parameter,
323
+ );
324
+ let strict = build_at_level(
325
+ ToolChoice::auto(),
326
+ vec![tool("search", Some(true))],
327
+ ToolStrictLevel::Auto,
328
+ );
329
+ let envelope_only = build_at_level(
330
+ ToolChoice::auto(),
331
+ vec![tool("search", None)],
332
+ ToolStrictLevel::Function,
333
+ );
334
+
335
+ assert_eq!(tag_string(&pinned), tag_string(&strict));
336
+ assert_ne!(tag_string(&pinned), tag_string(&envelope_only));
337
+ }
338
+
339
+ #[test]
340
+ fn tool_strict_level_parses_case_insensitively() {
341
+ assert_eq!("AUTO".parse::<ToolStrictLevel>(), Ok(ToolStrictLevel::Auto));
342
+ assert_eq!(
343
+ "PARAMETER".parse::<ToolStrictLevel>(),
344
+ Ok(ToolStrictLevel::Parameter)
345
+ );
346
+ assert_eq!(
347
+ "function".parse::<ToolStrictLevel>(),
348
+ Ok(ToolStrictLevel::Function)
349
+ );
350
+ assert_eq!(ToolStrictLevel::default(), ToolStrictLevel::Auto);
351
+ assert_eq!(
352
+ serde_json::to_value(ToolStrictLevel::Auto).unwrap(),
353
+ json!("auto")
354
+ );
355
+ assert_eq!(
356
+ serde_json::from_value::<ToolStrictLevel>(json!("auto")).unwrap(),
357
+ ToolStrictLevel::Auto
358
+ );
359
+ assert!("off".parse::<ToolStrictLevel>().is_err());
360
+ assert!("strict".parse::<ToolStrictLevel>().is_err());
361
+ }
362
+}
rust/src/parser/src/reasoning/mod.rs
+12
@@ -31,6 +31,9 @@ mod step3p5;
31
32
use thiserror::Error;
33
use vllm_tokenizer::{DecodedText, DynTokenizer};
34
+use xgrammar_structural_tag::format::Format;
35
+
36
+use crate::output_grammar::{self, OutputGrammarContext};
37
38
pub use self::cohere_cmd::CohereCmdReasoningParser;
39
pub use self::deepseek_r1::DeepSeekR1ReasoningParser;
@@ -137,6 +140,15 @@ pub trait ReasoningParser: Send {
140
false
141
}
142
143
+ /// Prepend the reasoning phase implied by the initialized parser state.
144
+ fn wrap_visible_format(
145
+ &self,
146
+ _ctx: &OutputGrammarContext<'_>,
147
+ _visible: &Format,
148
+ ) -> output_grammar::Result<Option<Format>> {
149
+ Ok(None)
150
+ }
151
+
152
/// Feed one decoded text delta into the parser.
153
fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta>;
154
rust/src/parser/src/tool/mod.rs
+18
@@ -39,7 +39,9 @@ pub use seed_oss::SeedOssToolParser;
39
use serde::{Deserialize, Serialize};
40
use serde_json::Value;
41
pub use xgrammar_structural_tag::builders::StructuralTagBuilder;
42
+use xgrammar_structural_tag::format::Format;
43
44
+use crate::output_grammar::{self, OutputGrammarContext};
45
use crate::utils;
46
47
/// One function-style tool made available to the model.
@@ -189,7 +191,23 @@ pub trait ToolParser: Send {
191
false
192
}
193
194
+ /// Build the language of everything the model may emit after reasoning
195
+ /// ends: visible text plus tool calls. `None` means no tool grammar applies.
196
+ ///
197
+ /// The default forwards to [`Self::structural_tag_builder`] with reasoning
198
+ /// disabled, which is exactly today's strict-tool-calling grammar.
199
+ fn build_visible_format(
200
+ &self,
201
+ ctx: &OutputGrammarContext<'_>,
202
+ ) -> output_grammar::Result<Option<Format>> {
203
+ output_grammar::visible_format_from_builder(self.structural_tag_builder(), ctx)
204
+ }
205
+
206
/// Return the xgrammar structural-tag builder used for strict tool calling.
207
+ ///
208
+ /// To be deprecated: this only exists to back the default
209
+ /// [`Self::build_visible_format`]. New parsers should override that method
210
+ /// directly instead of exposing a builder.
211
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
212
None
213
}
rust/src/parser/src/unified/combined.rs
+25
-4
@@ -6,8 +6,9 @@
6
use vllm_tokenizer::{DecodedText, DynTokenizer};
7
8
use super::{Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput};
9
+use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
10
use crate::reasoning::ReasoningParser;
10
-use crate::tool::{StructuralTagBuilder, Tool, ToolParser, ToolParserOutput};
11
+use crate::tool::{Tool, ToolParser, ToolParserOutput};
12
13
/// Unified parser that composes existing reasoning and tool parsers.
14
pub struct CombinedParser {
@@ -79,8 +80,29 @@ impl UnifiedParser for CombinedParser {
80
|| self.tool.as_ref().is_some_and(|parser| parser.preserve_special_tokens())
81
}
82
82
- fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
83
- self.tool.as_ref().and_then(|parser| parser.structural_tag_builder())
83
+ fn build_output_grammar(
84
+ &self,
85
+ ctx: &OutputGrammarContext<'_>,
86
+ ) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
87
+ let Some(tool) = self.tool.as_ref() else {
88
+ return Ok(None);
89
+ };
90
+ let Some(visible) = tool.build_visible_format(ctx)? else {
91
+ return Ok(None);
92
+ };
93
+ // The reasoning parser may wrap that language with the reasoning phase
94
+ // implied by its initialized (prompt-derived) state. If it does, the
95
+ // grammar describes the stream from the first generated token and the
96
+ // engine can skip its own reasoning gate; if it declines, the grammar
97
+ // covers only the final output and the engine gate stays in charge.
98
+ let wrapped = match self.reasoning.as_ref() {
99
+ Some(reasoning) => reasoning.wrap_visible_format(ctx, &visible)?,
100
+ None => None,
101
+ };
102
+ Ok(Some(match wrapped {
103
+ Some(full) => BuiltOutputGrammar::from_token_zero(full),
104
+ None => BuiltOutputGrammar::final_output_only(visible),
105
+ }))
106
}
107
108
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
@@ -332,7 +354,6 @@ mod tests {
354
fn combined_parser_emits_tool_calls_from_visible_content() {
355
let tool = Qwen3XmlToolParser::create(&test_tools()).unwrap();
356
let mut parser = CombinedParser::new(None, Some(tool));
335
- assert!(parser.structural_tag_builder().is_some());
357
358
let output = collect(
359
&mut parser,
rust/src/parser/src/unified/hy/hy_v3.rs
+22
-25
@@ -4,8 +4,9 @@
4
use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6
use super::detect_hy_token_suffix;
7
+use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
8
use crate::reasoning::HyReasoningParser;
8
-use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, StructuralTagBuilder, Tool};
9
+use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, Tool};
10
use crate::unified::{CombinedParser, Result, UnifiedParser, UnifiedParserOutput, token_id};
11
12
/// Unified reasoning and tool parser for HY3 output.
@@ -46,8 +47,11 @@ impl UnifiedParser for HyV3UnifiedParser {
47
self.inner.preserve_special_tokens()
48
}
49
49
- fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
50
- self.inner.structural_tag_builder()
50
+ fn build_output_grammar(
51
+ &self,
52
+ ctx: &OutputGrammarContext<'_>,
53
+ ) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
54
+ self.inner.build_output_grammar(ctx)
55
}
56
57
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
@@ -73,12 +77,10 @@ mod tests {
77
78
use serde_json::json;
79
use vllm_tokenizer::{DecodedText, Tokenizer, test_utils::TestTokenizer};
76
- use xgrammar_structural_tag::builders::StructuralTagOptions;
77
- use xgrammar_structural_tag::{
78
- FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
79
- };
80
+ use xgrammar_structural_tag::{StructuralTag, ToolChoice};
81
82
use super::{HyV3UnifiedParser, UnifiedParser};
83
+ use crate::output_grammar::{GrammarCoverage, OutputGrammarContext};
84
use crate::tool::Tool;
85
use crate::unified::{UnifiedParserEvent, UnifiedParserOutput};
86
@@ -151,24 +153,19 @@ mod tests {
153
154
#[test]
155
fn structural_tag_uses_tokenizer_detected_suffix() {
154
- let parser = HyV3UnifiedParser::new(&tools(), Arc::new(tokenizer())).unwrap();
155
- let structural_tools = [ToolParam::Function(FunctionToolParam::new(
156
- FunctionDefinition::new("get_weather").with_parameters(json!({
157
- "type": "object",
158
- "properties": { "city": { "type": "string" } },
159
- "required": ["city"]
160
- })),
161
- ))];
162
-
163
- let tag = build_structural_tag(
164
- parser.structural_tag_builder().unwrap(),
165
- &structural_tools,
166
- ToolChoice::required(),
167
- StructuralTagOptions::default().with_reasoning(false),
168
- )
169
- .unwrap()
170
- .to_json_string()
171
- .unwrap();
156
+ let tools = tools();
157
+ let parser = HyV3UnifiedParser::new(&tools, Arc::new(tokenizer())).unwrap();
158
+ let tag = parser
159
+ .build_output_grammar(&OutputGrammarContext {
160
+ tools: &tools,
161
+ tool_strict_level: Default::default(),
162
+ parallel_tool_calls: true,
163
+ tool_choice: &ToolChoice::required(),
164
+ })
165
+ .unwrap()
166
+ .unwrap();
167
+ assert_eq!(tag.coverage, GrammarCoverage::FinalOutputOnly);
168
+ let tag = StructuralTag::new(tag.format).to_json_string().unwrap();
169
170
assert!(tag.contains("<tool_calls:opensource>"));
171
assert!(tag.contains("<arg_value:opensource>"));
rust/src/parser/src/unified/hy/hy_v4.rs
+22
-25
@@ -4,8 +4,9 @@
4
use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6
use super::detect_hy_token_suffix;
7
+use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
8
use crate::reasoning::HyReasoningParser;
8
-use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, StructuralTagBuilder, Tool};
9
+use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, Tool};
10
use crate::unified::{CombinedParser, Result, UnifiedParser, UnifiedParserOutput, token_id};
11
12
/// Unified reasoning and tool parser for HY4 output.
@@ -46,8 +47,11 @@ impl UnifiedParser for HyV4UnifiedParser {
47
self.inner.preserve_special_tokens()
48
}
49
49
- fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
50
- self.inner.structural_tag_builder()
50
+ fn build_output_grammar(
51
+ &self,
52
+ ctx: &OutputGrammarContext<'_>,
53
+ ) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
54
+ self.inner.build_output_grammar(ctx)
55
}
56
57
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
@@ -73,12 +77,10 @@ mod tests {
77
78
use serde_json::json;
79
use vllm_tokenizer::{DecodedText, Tokenizer, test_utils::TestTokenizer};
76
- use xgrammar_structural_tag::builders::StructuralTagOptions;
77
- use xgrammar_structural_tag::{
78
- FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
79
- };
80
+ use xgrammar_structural_tag::{StructuralTag, ToolChoice};
81
82
use super::{HyV4UnifiedParser, UnifiedParser};
83
+ use crate::output_grammar::{GrammarCoverage, OutputGrammarContext};
84
use crate::tool::Tool;
85
use crate::unified::{UnifiedParserEvent, UnifiedParserOutput};
86
@@ -164,24 +166,19 @@ mod tests {
166
167
#[test]
168
fn structural_tag_uses_compact_hy4_skeleton() {
167
- let parser = HyV4UnifiedParser::new(&tools(), Arc::new(tokenizer())).unwrap();
168
- let structural_tools = [ToolParam::Function(FunctionToolParam::new(
169
- FunctionDefinition::new("get_weather").with_parameters(json!({
170
- "type": "object",
171
- "properties": { "city": { "type": "string" } },
172
- "required": ["city"]
173
- })),
174
- ))];
175
-
176
- let tag = build_structural_tag(
177
- parser.structural_tag_builder().unwrap(),
178
- &structural_tools,
179
- ToolChoice::required(),
180
- StructuralTagOptions::default().with_reasoning(false),
181
- )
182
- .unwrap()
183
- .to_json_string()
184
- .unwrap();
169
+ let tools = tools();
170
+ let parser = HyV4UnifiedParser::new(&tools, Arc::new(tokenizer())).unwrap();
171
+ let tag = parser
172
+ .build_output_grammar(&OutputGrammarContext {
173
+ tools: &tools,
174
+ tool_strict_level: Default::default(),
175
+ parallel_tool_calls: true,
176
+ tool_choice: &ToolChoice::required(),
177
+ })
178
+ .unwrap()
179
+ .unwrap();
180
+ assert_eq!(tag.coverage, GrammarCoverage::FinalOutputOnly);
181
+ let tag = StructuralTag::new(tag.format).to_json_string().unwrap();
182
183
assert!(tag.contains("<tool_calls:opensource>"));
184
assert!(tag.contains("<tool_call:opensource>get_weather"));
rust/src/parser/src/unified/kimi_k3.rs
+12
-3
@@ -51,7 +51,10 @@ use winnow::token::{literal, rest, take_till, take_until, take_while};
51
52
use self::structural_tag::KIMI_K3_STRUCTURAL_TAG_BUILDER;
53
use super::{Result, UnifiedParser, UnifiedParserOutput, token_id};
54
-use crate::tool::{StructuralTagBuilder, Tool, ToolCallDelta};
54
+use crate::output_grammar::{
55
+ self, BuiltOutputGrammar, OutputGrammarContext, visible_format_from_builder,
56
+};
57
+use crate::tool::{Tool, ToolCallDelta};
58
use crate::unified::parsing_failed;
59
use crate::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
60
@@ -275,8 +278,14 @@ impl UnifiedParser for KimiK3UnifiedParser {
278
true
279
}
280
278
- fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
279
- Some(&KIMI_K3_STRUCTURAL_TAG_BUILDER)
281
+ fn build_output_grammar(
282
+ &self,
283
+ ctx: &OutputGrammarContext<'_>,
284
+ ) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
285
+ Ok(
286
+ visible_format_from_builder(Some(&KIMI_K3_STRUCTURAL_TAG_BUILDER), ctx)?
287
+ .map(BuiltOutputGrammar::final_output_only),
288
+ )
289
}
290
291
fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
rust/src/parser/src/unified/mod.rs
+8
-6
@@ -18,10 +18,9 @@ use thiserror::Error;
18
use thiserror_ext::Macro;
19
use vllm_tokenizer::{DecodedText, DynTokenizer};
20
21
+use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
22
use crate::reasoning::ReasoningError;
22
-use crate::tool::{
23
- StructuralTagBuilder, Tool, ToolCallDelta, ToolParserError, ToolParserEvent, ToolParserOutput,
24
-};
23
+use crate::tool::{Tool, ToolCallDelta, ToolParserError, ToolParserEvent, ToolParserOutput};
24
25
/// Result alias for unified parser operations.
26
pub type Result<T> = std::result::Result<T, UnifiedParserError>;
@@ -240,9 +239,12 @@ pub trait UnifiedParser: Send {
239
false
240
}
241
243
- /// Return the xgrammar structural-tag builder used for strict tool calling.
244
- fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
245
- None
242
+ /// Build the request output grammar after prompt-based initialization.
243
+ fn build_output_grammar(
244
+ &self,
245
+ _ctx: &OutputGrammarContext<'_>,
246
+ ) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
247
+ Ok(None)
248
}
249
250
/// Return the parser-provided ID for a tool call by index, if the model emitted one.
rust/src/server/src/grpc/convert.rs
+2
-1
@@ -137,7 +137,8 @@ pub fn to_text_request(
137
data_parallel_rank: None,
138
session_id,
139
kv_hints,
140
- reasoning_parser_kwargs: None,
140
+ reasoning_parser_kwargs: Default::default(),
141
+ reasoning_ended: None,
142
lora_request: None,
143
arrival_time: None,
144
})
rust/src/server/src/routes/inference/generate/convert.rs
+2
-1
@@ -83,7 +83,8 @@ pub(super) fn prepare_generate_request(
83
data_parallel_rank: ctx.data_parallel_rank,
84
session_id: ctx.session_id,
85
kv_hints: None,
86
- reasoning_parser_kwargs: None,
86
+ reasoning_parser_kwargs: Default::default(),
87
+ reasoning_ended: None,
88
lora_request: lora_resolution.lora_request.clone(),
89
arrival_time: None,
90
};
rust/src/server/src/routes/openai/completions/convert.rs
+2
-1
@@ -156,7 +156,8 @@ pub(super) fn prepare_completion_request(
156
data_parallel_rank: ctx.data_parallel_rank,
157
session_id,
158
kv_hints: None,
159
- reasoning_parser_kwargs: None,
159
+ reasoning_parser_kwargs: Default::default(),
160
+ reasoning_ended: None,
161
lora_request: lora_resolution.lora_request.clone(),
162
arrival_time: None,
163
};
rust/src/server/src/routes/render.rs
+1
-1
@@ -124,7 +124,7 @@ async fn render_chat(
124
let chat_request = lower_chat_request(body, &model_resolution(&state), request_context)?;
125
let (text_request, _) = state
126
.chat
127
- .prepare(chat_request)
127
+ .prepare(chat_request, &state.text)
128
.await
129
.map_err(|error| ApiError::invalid_request(error.to_report_string(), None))?;
130
Ok(Json(lower_render_request(
rust/src/server/src/routes/tokenize/types.rs
+2
-1
@@ -55,7 +55,8 @@ impl TokenizeCompletionRequest {
55
data_parallel_rank: None,
56
session_id: None,
57
kv_hints: None,
58
- reasoning_parser_kwargs: None,
58
+ reasoning_parser_kwargs: Default::default(),
59
+ reasoning_ended: None,
60
lora_request: None,
61
arrival_time: None,
62
}
rust/src/text/src/lib.rs
+21
@@ -104,6 +104,27 @@ impl TextRequestProcessor {
104
Ok(prompt_token_ids)
105
}
106
107
+ /// Tokenize and truncate the prompt in place, so that later preparation
108
+ /// reuses the final token IDs instead of tokenizing again.
109
+ ///
110
+ /// Frontends that need the final prompt tokens before submission (for
111
+ /// example, to initialize a prompt-aware output parser) call this first
112
+ /// and then hand the same request to [`TextLlm::generate`].
113
+ pub fn tokenize_in_place<'r>(&self, request: &'r mut TextRequest) -> Result<&'r [u32]> {
114
+ request.validate()?;
115
+ let prompt_token_ids = self.prepare_prompt_tokens(
116
+ take(&mut request.prompt),
117
+ request.add_special_tokens,
118
+ request.prompt_truncation.take(),
119
+ request.sampling_params.max_tokens,
120
+ )?;
121
+ request.prompt = Prompt::TokenIds(prompt_token_ids);
122
+ match &request.prompt {
123
+ Prompt::TokenIds(prompt_token_ids) => Ok(prompt_token_ids),
124
+ Prompt::Text(_) => unreachable!("prompt was just replaced with token IDs"),
125
+ }
126
+ }
127
+
128
/// Prepare one request's prompt tokens without generation-specific lowering.
129
pub fn tokenize(&self, request: TextRequest) -> Result<Vec<u32>> {
130
request.validate()?;
rust/src/text/src/lower.rs
+2
-1
@@ -61,7 +61,8 @@ pub fn lower_text_request(
61
data_parallel_rank: request.data_parallel_rank,
62
session_id: request.session_id.clone(),
63
kv_hints: request.kv_hints.clone(),
64
- reasoning_parser_kwargs: request.reasoning_parser_kwargs.clone(),
64
+ reasoning_parser_kwargs: Some(request.reasoning_parser_kwargs.clone()),
65
+ reasoning_ended: request.reasoning_ended,
66
lora_request: request.lora_request.clone(),
67
arrival_time: request.arrival_time,
68
trace_headers: None,
rust/src/text/src/request.rs
+14
-4
@@ -215,10 +215,19 @@ pub struct TextRequest {
215
/// Optional orchestrator-originated KV hints.
216
#[serde(default)]
217
pub kv_hints: Option<KvHintsEnvelope>,
218
- /// Optional reasoning-parser kwargs forwarded to engine-side structured
219
- /// output logic.
218
+ /// Reasoning-parser kwargs forwarded to engine-side structured output
219
+ /// logic. The engine consults them only when it owns grammar activation;
220
+ /// see [`Self::reasoning_ended`].
221
#[serde(default)]
221
- pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
222
+ pub reasoning_parser_kwargs: ReasoningParserKwargs,
223
+ /// Optional engine reasoning-gate override selected by a higher-level frontend.
224
+ ///
225
+ /// `Some(true)` means the structured output grammar covers reasoning from
226
+ /// the first generated token, so the engine masks and advances immediately
227
+ /// instead of waiting for its reasoning parser. Unset for final-output-only
228
+ /// grammars and for requests without a grammar.
229
+ #[serde(default)]
230
+ pub reasoning_ended: Option<bool>,
231
/// LoRA adapter selected for this request.
232
#[serde(default)]
233
pub lora_request: Option<LoraRequest>,
@@ -247,7 +256,8 @@ impl TextRequest {
256
data_parallel_rank: None,
257
session_id: None,
258
kv_hints: None,
250
- reasoning_parser_kwargs: None,
259
+ reasoning_parser_kwargs: Default::default(),
260
+ reasoning_ended: None,
261
lora_request: None,
262
arrival_time: None,
263
}