[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>
This commit is contained in:
Bugen Zhao
2026-09-22 05:35:07 +00:00
committed by GitHub
co-authored by Claude Fable 5.1
parent f84325c48c
commit d50723df04
34 changed files with 814 additions and 698 deletions
+1
View File
@@ -5937,6 +5937,7 @@ dependencies = [
"openai-protocol",
"serde",
"serde_json",
"serde_with",
"thiserror 2.0.18",
"thiserror-ext",
"tool-parser",
+10 -2
View File
@@ -60,6 +60,11 @@ pub enum Error {
#[source]
error: BoxedError,
},
#[error("failed to initialize request output parser")]
OutputParserInitialization {
#[source]
error: BoxedError,
},
#[error(
"gpt_oss uses native Harmony output parsing; generic {kind} parser override `{selection}` is not supported"
)]
@@ -87,8 +92,11 @@ pub enum Error {
ToolChoiceRequiresTools,
#[error("tool_choice function `{name}` was not found in the available tools")]
ToolChoiceFunctionNotFound { name: String },
#[error("failed to build structural tag: {message}")]
StructuralTag { message: String },
#[error("failed to build output grammar")]
OutputGrammar {
#[source]
error: BoxedError,
},
#[error(transparent)]
Text(#[from] vllm_text::Error),
#[error(transparent)]
+29 -14
View File
@@ -22,6 +22,7 @@ pub use event::{
};
use futures::{StreamExt, TryStreamExt as _};
pub use llm_multimodal::MediaContentPart;
use output::apply_output_grammar;
pub use output::{
ChatOutputProcessor, DefaultChatOutputProcessor, DynChatOutputProcessor,
HarmonyChatOutputProcessor,
@@ -65,7 +66,7 @@ use vllm_engine_core_client::EngineCoreClient;
use vllm_engine_core_client::protocol::dtype::ModelDtype;
use vllm_engine_core_client::protocol::request::ReasoningParserKwargs;
use vllm_llm::Llm;
use vllm_text::{Prompt, TextLlm, TextRequest};
use vllm_text::{Prompt, TextLlm, TextRequest, TextRequestProcessor};
/// Chat request preparation shared by inference and render-only frontends.
pub struct ChatRequestProcessor {
@@ -174,15 +175,8 @@ impl ChatRequestProcessor {
async fn prepare_text_request(&self, request: ChatRequest) -> Result<TextRequest> {
// Stamp before rendering so render and tokenize count toward TTFT/e2e.
let arrival_time = vllm_llm::current_unix_timestamp_secs();
let rendered = self.backend.chat_renderer().render(&request)?;
let reasoning_parser_kwargs =
request
.sampling_params
.structured_outputs
.is_some()
.then(|| ReasoningParserKwargs {
chat_template_kwargs: rendered.effective_template_kwargs.clone(),
});
let mut rendered = self.backend.chat_renderer().render(&request)?;
let chat_template_kwargs = std::mem::take(&mut rendered.effective_template_kwargs);
let (prompt, mm_features) = self.finalize_rendered_prompt(&request, rendered).await?;
Ok(TextRequest {
request_id: request.request_id,
@@ -198,7 +192,10 @@ impl ChatRequestProcessor {
data_parallel_rank: request.data_parallel_rank,
session_id: request.session_id,
kv_hints: None,
reasoning_parser_kwargs,
reasoning_parser_kwargs: ReasoningParserKwargs {
chat_template_kwargs,
},
reasoning_ended: None,
lora_request: request.lora_request,
arrival_time: Some(arrival_time),
})
@@ -211,12 +208,19 @@ impl ChatRequestProcessor {
}
/// Prepare one chat request without submitting it to an engine.
///
/// The returned text request is already tokenized: the output processor
/// is initialized from the final prompt token IDs and its grammar, if any,
/// is applied to the request before it is handed to `text_processor` again
/// for lowering.
pub async fn prepare(
&self,
mut request: ChatRequest,
text_processor: &TextRequestProcessor,
) -> Result<(TextRequest, DynChatOutputProcessor)> {
request.validate()?;
let output_processor = self.backend.new_chat_output_processor(
let mut output_processor = self.backend.new_chat_output_processor(
&mut request,
NewChatOutputProcessorOptions {
tool_call_parser: &self.tool_call_parser,
@@ -224,7 +228,17 @@ impl ChatRequestProcessor {
tool_strict_level: self.tool_strict_level,
},
)?;
let text_request = self.prepare_text_request(request).await?;
let mut text_request = self.prepare_text_request(request).await?;
// Initialize the output processor from the final prompt token IDs and apply
// the output grammar to the request, if any.
let prompt_token_ids = text_processor.tokenize_in_place(&mut text_request)?;
let grammar = {
output_processor.initialize(prompt_token_ids)?;
output_processor.build_output_grammar()?
};
apply_output_grammar(&mut text_request, grammar)?;
Ok((text_request, output_processor))
}
}
@@ -342,7 +356,8 @@ impl ChatLlm {
/// Render, tokenize, and submit one chat request.
pub async fn chat(&self, request: ChatRequest) -> Result<ChatEventStream> {
let (text_request, output_processor) = self.processor.prepare(request).await?;
let (text_request, output_processor) =
self.processor.prepare(request, self.text.request_processor()).await?;
let request_id = text_request.request_id.clone();
let decoded_stream = self.text.generate(text_request).await?.map_err(Error::from).boxed();
+125 -10
View File
@@ -3,17 +3,17 @@
//! Default output processing pipeline.
mod structural_tag;
mod unified;
use std::sync::Once;
use futures::StreamExt as _;
use tracing::info;
use vllm_parser::output_grammar::{BuiltOutputGrammar, OutputGrammarContext};
use vllm_parser::unified::{CombinedParser, UnifiedParser};
use vllm_text::tokenizer::DynTokenizer;
use xgrammar_structural_tag::ToolChoice;
use self::structural_tag::apply_structural_tag_constraint;
use self::unified::unified_event_stream;
use super::structured::structured_chat_event_stream;
use crate::error::Result;
@@ -34,6 +34,18 @@ use crate::{Error, Result as ChatResult};
pub struct DefaultChatOutputProcessor {
parser: Box<dyn UnifiedParser>,
parallel_tool_calls: bool,
/// Request facts the initialized parser needs to build its output grammar.
/// Absent for the plain-text-only processor, which never builds one.
grammar_inputs: Option<GrammarInputs>,
}
/// Request-scoped inputs for [`UnifiedParser::build_output_grammar`], captured
/// at construction because the chat request is consumed before the prompt is
/// tokenized.
struct GrammarInputs {
tools: Vec<ChatTool>,
tool_choice: ToolChoice,
tool_strict_level: ToolStrictLevel,
}
impl DefaultChatOutputProcessor {
@@ -42,7 +54,8 @@ impl DefaultChatOutputProcessor {
///
/// Parser resolution happens here so that request validation, prompt
/// rendering, and streaming all observe the same parser-adjusted
/// request state.
/// request state. The parser is initialized and its output grammar built
/// later, once the final prompt token IDs are known.
pub fn new(
request: &mut ChatRequest,
model_id: &str,
@@ -74,12 +87,6 @@ impl DefaultChatOutputProcessor {
Box::new(CombinedParser::new(reasoning_parser, tool_parser)) as Box<dyn UnifiedParser>
};
apply_structural_tag_constraint(
request,
parser.structural_tag_builder(),
tool_strict_level,
)?;
if parser.preserve_special_tokens() {
request.decode_options.skip_special_tokens = false;
}
@@ -87,6 +94,11 @@ impl DefaultChatOutputProcessor {
Ok(Self {
parser,
parallel_tool_calls: request.parallel_tool_calls(),
grammar_inputs: Some(GrammarInputs {
tools: request.tools().to_vec(),
tool_choice: request.tool_choice().into(),
tool_strict_level,
}),
})
}
@@ -99,6 +111,7 @@ impl DefaultChatOutputProcessor {
Self {
parser: Box::new(CombinedParser::plain_text_only()),
parallel_tool_calls: true,
grammar_inputs: None,
}
}
@@ -175,6 +188,30 @@ static REASONING_PARSER_LOG_ONCE: Once = Once::new();
static UNIFIED_PARSER_LOG_ONCE: Once = Once::new();
impl ChatOutputProcessor for DefaultChatOutputProcessor {
fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
self.parser.initialize(prompt_token_ids).map_err(|error| {
Error::OutputParserInitialization {
error: Box::new(error),
}
})
}
fn build_output_grammar(&self) -> Result<Option<BuiltOutputGrammar>> {
let Some(inputs) = &self.grammar_inputs else {
return Ok(None);
};
self.parser
.build_output_grammar(&OutputGrammarContext {
tools: &inputs.tools,
tool_choice: &inputs.tool_choice,
tool_strict_level: inputs.tool_strict_level,
parallel_tool_calls: self.parallel_tool_calls,
})
.map_err(|error| Error::OutputGrammar {
error: Box::new(error),
})
}
/// Transforms a raw generate-output token stream into structured chat
/// events through two sequential stages once text decoding has
/// already happened:
@@ -196,8 +233,9 @@ mod tests {
use vllm_tokenizer::test_utils::TestTokenizer;
use super::DefaultChatOutputProcessor;
use crate::output::ChatOutputProcessor;
use crate::parser::{ParserSelection, ToolStrictLevel};
use crate::request::ChatRequest;
use crate::request::{ChatRequest, ChatTool, ChatToolChoice, ResolvedToolContext};
fn tokenizer() -> Arc<TestTokenizer> {
Arc::new(
@@ -207,6 +245,83 @@ mod tests {
)
}
#[test]
fn output_grammar_preserves_tool_strict_level() {
let build = |level, strict, choice| {
let tools = vec![ChatTool {
name: "search".to_string(),
description: None,
parameters: serde_json::json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}),
strict,
}];
let mut request = ChatRequest {
tool_context: ResolvedToolContext::new(&[], tools, Some(choice), true).unwrap(),
..ChatRequest::for_test()
};
let mut processor = DefaultChatOutputProcessor::new(
&mut request,
"other-model",
tokenizer(),
&ParserSelection::Explicit("qwen3_coder".to_string()),
&ParserSelection::None,
level,
)
.unwrap();
processor.initialize(&[]).unwrap();
processor.build_output_grammar().unwrap()
};
assert!(build(ToolStrictLevel::Auto, None, ChatToolChoice::Auto).is_none());
let envelope = build(ToolStrictLevel::Function, None, ChatToolChoice::Auto).unwrap();
let parameters = build(ToolStrictLevel::Parameter, None, ChatToolChoice::Auto).unwrap();
let strict = build(ToolStrictLevel::Auto, Some(true), ChatToolChoice::Auto).unwrap();
assert_eq!(parameters, strict);
assert_ne!(envelope, parameters);
assert!(build(ToolStrictLevel::Parameter, None, ChatToolChoice::None).is_none());
}
#[test]
fn tool_grammar_honors_parallel_call_policy() {
for parallel in [false, true] {
let tools = vec![ChatTool {
name: "lookup".to_string(),
description: None,
parameters: serde_json::json!({"type": "object"}),
strict: Some(true),
}];
let mut request = ChatRequest {
tool_context: ResolvedToolContext::new(
&[],
tools,
Some(ChatToolChoice::Auto),
parallel,
)
.unwrap(),
..ChatRequest::for_test()
};
let mut processor = DefaultChatOutputProcessor::new(
&mut request,
"other-model",
tokenizer(),
&ParserSelection::Explicit("qwen3_coder".to_string()),
&ParserSelection::None,
ToolStrictLevel::Auto,
)
.unwrap();
processor.initialize(&[]).unwrap();
let built = processor.build_output_grammar().unwrap().unwrap();
let tag =
serde_json::to_value(xgrammar_structural_tag::StructuralTag::new(built.format))
.unwrap();
assert_eq!(tag["format"]["type"], "triggered_tags");
assert_eq!(tag["format"]["stop_after_first"], !parallel);
}
}
#[test]
fn equal_explicit_gemma4_uses_unified_parser() {
let mut request = ChatRequest::for_test();
@@ -1,507 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Applies xgrammar structural-tag constraints for strict tool calling.
use thiserror_ext::AsReport;
use vllm_engine_core_client::protocol::structured_outputs::{
StructuredOutputBackend, StructuredOutputsParams,
};
use vllm_parser::tool::StructuralTagBuilder;
use xgrammar_structural_tag::builders::StructuralTagOptions;
use xgrammar_structural_tag::{
FunctionDefinition, FunctionToolParam, ToolChoice as StructuralTagToolChoice, ToolParam,
build_structural_tag,
};
use crate::parser::ToolStrictLevel;
use crate::request::{ChatRequest, ChatToolChoice};
use crate::{Error, Result as ChatResult};
/// Apply structural tag constraints to the request based on the tool parser's structural tag
/// support, the request's tool choice, and the server-side strictness floor.
pub(super) fn apply_structural_tag_constraint(
request: &mut ChatRequest,
builder: Option<&dyn StructuralTagBuilder>,
strict_level: ToolStrictLevel,
) -> ChatResult<()> {
let Some(builder) = builder else {
return Ok(());
};
let Some(tool_choice) = structural_tag_tool_choice(request, strict_level) else {
return Ok(());
};
let tools = request
.tools()
.iter()
.map(|tool| {
// A tool without an explicit `strict` is non-strict: its call envelope is
// constrained, but its arguments stay free unless the server level is `parameter`.
let strict = strict_level >= ToolStrictLevel::Parameter || tool.strict == Some(true);
ToolParam::Function(FunctionToolParam::new(FunctionDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: Some(tool.parameters.clone()),
strict: Some(strict),
}))
})
.collect::<Vec<_>>();
let structural_tag = build_structural_tag(
builder,
&tools,
tool_choice,
StructuralTagOptions::default()
.with_reasoning(false)
.with_parallel_tool_calls(request.parallel_tool_calls()),
)
.and_then(|tag| tag.to_json_string())
.map_err(|error| Error::StructuralTag {
message: error.to_report_string(),
})?;
// Overwrite any existing structured output settings with the structural tag constraint.
request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
backend: StructuredOutputBackend::Xgrammar,
..StructuredOutputsParams::structural_tag(structural_tag)
});
Ok(())
}
/// Resolve the tool choice used for [`xgrammar_structural_tag`] based on the request.
///
/// Returns `None` if no structural tag constraints should be applied.
fn structural_tag_tool_choice(
request: &ChatRequest,
strict_level: ToolStrictLevel,
) -> Option<StructuralTagToolChoice> {
if request.tools().is_empty() {
return None;
}
match request.tool_choice() {
ChatToolChoice::None => None,
// For `Auto`, apply the structural tag only when the server raises the floor or at
// least one tool opts in with `strict: true`.
ChatToolChoice::Auto => (strict_level >= ToolStrictLevel::Function
|| request.tools().iter().any(|tool| tool.strict == Some(true)))
.then(StructuralTagToolChoice::auto),
ChatToolChoice::Required => Some(StructuralTagToolChoice::required()),
ChatToolChoice::Function { name } => Some(StructuralTagToolChoice::function(name.clone())),
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use vllm_engine_core_client::protocol::structured_outputs::{
StructuredOutputBackend, StructuredOutputsParams,
};
use vllm_parser::tool::{Qwen3CoderToolParser, Tool, ToolParser};
use super::*;
use crate::request::{ChatMessage, ResolvedToolContext};
fn chat_tool(name: &str, strict: Option<bool>) -> Tool {
Tool {
name: name.to_string(),
description: None,
parameters: json!({
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}),
strict,
}
}
fn qwen3_coder_parser(tools: &[Tool]) -> Box<dyn ToolParser> {
Qwen3CoderToolParser::create(tools).expect("Qwen3 Coder parser should build")
}
fn request(tool_choice: ChatToolChoice, tools: Vec<Tool>) -> ChatRequest {
ChatRequest {
tool_context: ResolvedToolContext::new(&[], tools, Some(tool_choice), true)
.expect("tool context should resolve"),
..ChatRequest::for_test()
}
}
fn structural_tag_value(request: &ChatRequest) -> Value {
let params = request
.sampling_params
.structured_outputs
.as_ref()
.expect("structured outputs should be set");
assert_eq!(params.backend, StructuredOutputBackend::Xgrammar);
let structural_tag = params
.constraint
.as_structural_tag()
.expect("structured output constraint should be structural_tag");
serde_json::from_str(structural_tag).expect("structural_tag should be valid JSON")
}
fn structured_outputs(request: &ChatRequest) -> &StructuredOutputsParams {
request
.sampling_params
.structured_outputs
.as_ref()
.expect("structured outputs should be set")
}
#[test]
fn tool_grammar_honors_parallel_call_policy() {
for parallel in [false, true] {
let tools = vec![chat_tool("lookup", Some(true))];
let mut request = ChatRequest {
tool_context: ResolvedToolContext::new(
&[],
tools,
Some(ChatToolChoice::Auto),
parallel,
)
.unwrap(),
..ChatRequest::for_test()
};
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.unwrap();
let tag = structural_tag_value(&request);
assert_eq!(tag["format"]["type"], "triggered_tags");
assert_eq!(tag["format"]["stop_after_first"], !parallel);
}
}
#[test]
fn auto_strict_tool_choice_builds_structural_tag() {
let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag should build");
let tag = structural_tag_value(&request);
assert_eq!(tag["type"], "structural_tag");
assert!(tag.to_string().contains("search"));
}
#[test]
fn required_dynamic_tool_builds_structural_tag_from_effective_tools() {
let messages = vec![ChatMessage::developer(
"",
Some(vec![chat_tool("lookup", None)]),
)];
let tool_context =
ResolvedToolContext::new(&messages, Vec::new(), Some(ChatToolChoice::Required), true)
.expect("dynamic tool context should resolve");
let mut request = ChatRequest {
messages,
tool_context,
..ChatRequest::for_test()
};
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag should build");
let tag = structural_tag_value(&request);
assert!(tag.to_string().contains("lookup"));
}
#[test]
fn required_initial_and_dynamic_tools_build_one_structural_tag() {
let messages = vec![ChatMessage::developer(
"",
Some(vec![chat_tool("lookup", None)]),
)];
let tool_context = ResolvedToolContext::new(
&messages,
vec![chat_tool("search", None)],
Some(ChatToolChoice::Required),
true,
)
.expect("tool context should resolve");
let mut request = ChatRequest {
messages,
tool_context,
..ChatRequest::for_test()
};
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag should build");
let tag = structural_tag_value(&request).to_string();
assert!(tag.contains("search"));
assert!(tag.contains("lookup"));
}
#[test]
fn auto_non_strict_tool_choice_skips_structural_tag() {
let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", None)]);
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag decision should succeed");
assert!(request.sampling_params.structured_outputs.is_none());
}
#[test]
fn auto_strict_tool_choice_overwrites_existing_json_guidance() {
let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
backend: StructuredOutputBackend::Xgrammar,
..StructuredOutputsParams::json(json!({"type": "object"}))
});
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag should build");
let params = structured_outputs(&request);
assert!(params.constraint.is_structural_tag());
let tag = structural_tag_value(&request);
assert_eq!(tag["type"], "structural_tag");
assert!(tag.to_string().contains("search"));
}
#[test]
fn required_tool_choice_builds_structural_tag_without_strict_tools() {
let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag should build");
let tag = structural_tag_value(&request);
assert_eq!(tag["type"], "structural_tag");
assert!(tag.to_string().contains("search"));
}
#[test]
fn required_tool_choice_overwrites_existing_json_object_guidance() {
let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
backend: StructuredOutputBackend::Xgrammar,
..StructuredOutputsParams::json_object()
});
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag should build");
let params = structured_outputs(&request);
assert!(params.constraint.is_structural_tag());
let tag = structural_tag_value(&request);
assert_eq!(tag["type"], "structural_tag");
assert!(tag.to_string().contains("search"));
}
#[test]
fn named_tool_choice_builds_structural_tag_for_named_tool_only() {
let mut request = request(
ChatToolChoice::Function {
name: "lookup".to_string(),
},
vec![chat_tool("search", None), chat_tool("lookup", None)],
);
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag should build");
let tag = structural_tag_value(&request).to_string();
assert!(tag.contains("lookup"));
assert!(!tag.contains("search"));
}
#[test]
fn none_tool_choice_skips_structural_tag() {
let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag decision should succeed");
assert!(request.sampling_params.structured_outputs.is_none());
}
#[test]
fn none_tool_choice_preserves_existing_json_object_guidance() {
let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
backend: StructuredOutputBackend::Xgrammar,
..StructuredOutputsParams::json_object()
});
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(
&mut request,
parser.structural_tag_builder(),
ToolStrictLevel::Auto,
)
.expect("structural tag decision should succeed");
let params = structured_outputs(&request);
assert!(params.constraint.is_json_object());
}
fn build(tool_choice: ChatToolChoice, tools: Vec<Tool>, level: ToolStrictLevel) -> ChatRequest {
let mut request = request(tool_choice, tools);
let parser = qwen3_coder_parser(request.tools());
apply_structural_tag_constraint(&mut request, parser.structural_tag_builder(), level)
.expect("structural tag decision should succeed");
request
}
fn tag_string(request: &ChatRequest) -> String {
structural_tag_value(request).to_string()
}
#[test]
fn function_level_constrains_auto_without_strict_tools() {
let request = build(
ChatToolChoice::Auto,
vec![chat_tool("search", None)],
ToolStrictLevel::Function,
);
let tag = structural_tag_value(&request);
assert_eq!(tag["type"], "structural_tag");
assert!(tag.to_string().contains("search"));
}
#[test]
fn absent_strict_is_non_strict() {
let unset = build(
ChatToolChoice::Required,
vec![chat_tool("search", None)],
ToolStrictLevel::Auto,
);
let non_strict = build(
ChatToolChoice::Required,
vec![chat_tool("search", Some(false))],
ToolStrictLevel::Auto,
);
let strict = build(
ChatToolChoice::Required,
vec![chat_tool("search", Some(true))],
ToolStrictLevel::Auto,
);
assert_eq!(tag_string(&unset), tag_string(&non_strict));
assert_ne!(tag_string(&unset), tag_string(&strict));
}
#[test]
fn strict_tool_does_not_pin_its_neighbour() {
let mixed = build(
ChatToolChoice::Auto,
vec![chat_tool("weather", Some(true)), chat_tool("search", None)],
ToolStrictLevel::Auto,
);
let explicit = build(
ChatToolChoice::Auto,
vec![
chat_tool("weather", Some(true)),
chat_tool("search", Some(false)),
],
ToolStrictLevel::Auto,
);
assert_eq!(tag_string(&mixed), tag_string(&explicit));
}
#[test]
fn parameter_level_pins_argument_schemas() {
let pinned = build(
ChatToolChoice::Auto,
vec![chat_tool("search", None)],
ToolStrictLevel::Parameter,
);
let strict = build(
ChatToolChoice::Auto,
vec![chat_tool("search", Some(true))],
ToolStrictLevel::Auto,
);
let envelope_only = build(
ChatToolChoice::Auto,
vec![chat_tool("search", None)],
ToolStrictLevel::Function,
);
assert_eq!(tag_string(&pinned), tag_string(&strict));
assert_ne!(tag_string(&pinned), tag_string(&envelope_only));
}
#[test]
fn tool_strict_level_parses_case_insensitively() {
assert_eq!("AUTO".parse::<ToolStrictLevel>(), Ok(ToolStrictLevel::Auto));
assert_eq!(
"PARAMETER".parse::<ToolStrictLevel>(),
Ok(ToolStrictLevel::Parameter)
);
assert_eq!(
"function".parse::<ToolStrictLevel>(),
Ok(ToolStrictLevel::Function)
);
assert_eq!(ToolStrictLevel::default(), ToolStrictLevel::Auto);
assert_eq!(
serde_json::to_value(ToolStrictLevel::Auto).unwrap(),
json!("auto")
);
assert_eq!(
serde_json::from_value::<ToolStrictLevel>(json!("auto")).unwrap(),
ToolStrictLevel::Auto
);
assert!("off".parse::<ToolStrictLevel>().is_err());
assert!("strict".parse::<ToolStrictLevel>().is_err());
}
}
@@ -48,25 +48,6 @@ impl UnifiedParserState {
}
}
/// Initialize parser state once prompt token IDs are available.
fn initialize(&mut self, prompt_token_ids: &[u32]) {
if self.parser_failed {
return;
}
match self.parser.initialize(prompt_token_ids) {
Ok(()) => {}
Err(error) => {
warn!(
error = %error.as_report(),
"failed to initialize unified parser; falling back to plain text deltas"
);
self.parser_failed = true;
self.open_call_index = None;
}
}
}
/// Convert one decoded text delta into zero or more parsed assistant events.
fn process_delta(&mut self, delta: DecodedText) -> Result<Vec<AssistantEvent>> {
if self.parser_failed {
@@ -262,7 +243,6 @@ pub(crate) async fn unified_event_stream(
prompt_token_ids,
prompt_logprobs,
} => {
state.initialize(&prompt_token_ids);
y.yield_ok(AssistantEvent::Start {
prompt_token_ids,
prompt_logprobs,
+20
View File
@@ -7,6 +7,7 @@ use std::sync::Arc;
use futures::Stream;
use trait_set::trait_set;
use uuid::Uuid;
use vllm_parser::output_grammar::BuiltOutputGrammar;
use vllm_text::output::{DecodedLogprobs, DecodedPromptLogprobs, DecodedTextEvent};
use crate::FinishReason;
@@ -15,11 +16,13 @@ use crate::event::{AssistantBlockKind, ChatEvent, ChatTokenUsage};
mod default;
mod harmony;
mod structural_tag;
mod structured;
pub use default::DefaultChatOutputProcessor;
pub use harmony::HarmonyChatOutputProcessor;
pub(crate) use harmony::validate_harmony_parser_overrides;
pub(crate) use structural_tag::apply_output_grammar;
/// Internal assistant event before final assembly.
///
@@ -65,7 +68,24 @@ pub type DynChatEventStream = Pin<Box<dyn Stream<Item = Result<ChatEvent>> + Sen
/// Request-scoped output processor from decoded text events into structured
/// chat events.
///
/// A processor is created before the prompt is rendered, so that parser
/// resolution can adjust the request, and then goes through two more phases
/// once the final prompt token IDs are known: [`Self::initialize`] and
/// [`Self::build_output_grammar`], in that order, both before
/// [`Self::process`].
pub trait ChatOutputProcessor: Send {
/// Initialize request-scoped parser state from the final prompt token IDs.
fn initialize(&mut self, _prompt_token_ids: &[u32]) -> Result<()> {
Ok(())
}
/// Build the structured output grammar implied by the initialized parser
/// state, or `None` when this request needs no parser-owned grammar.
fn build_output_grammar(&self) -> Result<Option<BuiltOutputGrammar>> {
Ok(None)
}
/// Consume decoded text stream and return the structured chat-event stream.
fn process(self: Box<Self>, decoded: DynDecodedTextEventStream) -> Result<DynChatEventStream>;
}
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Applies parser-built xgrammar structural-tag constraints.
use vllm_engine_core_client::protocol::structured_outputs::{
StructuredOutputBackend, StructuredOutputsParams,
};
use vllm_parser::output_grammar::{BuiltOutputGrammar, GrammarCoverage};
use vllm_text::TextRequest;
use xgrammar_structural_tag::StructuralTag;
use crate::{Error, Result};
/// Apply one parser-built output grammar to the prepared text request.
///
/// A parser-owned grammar replaces any structured output constraint the user
/// supplied, matching the Python frontend. `None` leaves the request untouched.
pub(crate) fn apply_output_grammar(
request: &mut TextRequest,
built: Option<BuiltOutputGrammar>,
) -> Result<()> {
let Some(built) = built else {
return Ok(());
};
let structural_tag = StructuralTag::new(built.format).to_json_string().map_err(|error| {
Error::OutputGrammar {
error: Box::new(error),
}
})?;
// Overwrite any existing structured output settings with the structural tag constraint.
request.sampling_params.structured_outputs = Some(StructuredOutputsParams {
backend: StructuredOutputBackend::Xgrammar,
..StructuredOutputsParams::structural_tag(structural_tag)
});
request.reasoning_ended = match built.coverage {
GrammarCoverage::FromTokenZero => Some(true),
GrammarCoverage::FinalOutputOnly => None,
};
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vllm_engine_core_client::protocol::structured_outputs::{
StructuredOutputBackend, StructuredOutputsParams,
};
use xgrammar_structural_tag::format::Format;
use super::*;
#[test]
fn output_grammar_overwrites_answer_constraint() {
let mut request = TextRequest::for_test();
request.sampling_params.structured_outputs =
Some(StructuredOutputsParams::json(json!({ "type": "object" })));
let built = BuiltOutputGrammar::from_token_zero(Format::const_string("answer"));
apply_output_grammar(&mut request, Some(built)).unwrap();
assert_eq!(request.reasoning_ended, Some(true));
let params = request.sampling_params.structured_outputs.unwrap();
assert_eq!(params.backend, StructuredOutputBackend::Xgrammar);
let serialized = params.constraint.as_structural_tag().unwrap();
let value: serde_json::Value = serde_json::from_str(serialized).unwrap();
assert_eq!(value["type"], "structural_tag");
assert_eq!(value["format"]["type"], "const_string");
assert_eq!(value["format"]["value"], "answer");
}
}
+2 -60
View File
@@ -12,6 +12,8 @@ use std::str::FromStr;
use serde_with::{DeserializeFromStr, SerializeDisplay};
pub use vllm_parser::output_grammar::ToolStrictLevel;
use self::reasoning::ReasoningParserFactory;
use self::tool::ToolParserFactory;
use self::unified::UnifiedParserFactory;
@@ -57,66 +59,6 @@ impl ParserSelection {
}
}
/// Server-side floor for tool-call structural tags (`--tool-strict-level`).
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Default,
DeserializeFromStr,
SerializeDisplay,
)]
pub enum ToolStrictLevel {
/// Derive tool constraints from the request's tool choice and per-tool strictness.
#[default]
Auto,
/// Constrain the tool-call envelope for every request with tools.
Function,
/// Additionally pin argument schemas, as if every tool were `strict: true`.
Parameter,
}
impl ToolStrictLevel {
pub const AUTO_LITERAL: &str = "auto";
pub const FUNCTION_LITERAL: &str = "function";
pub const PARAMETER_LITERAL: &str = "parameter";
}
impl FromStr for ToolStrictLevel {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.eq_ignore_ascii_case(Self::AUTO_LITERAL) {
Ok(Self::Auto)
} else if value.eq_ignore_ascii_case(Self::FUNCTION_LITERAL) {
Ok(Self::Function)
} else if value.eq_ignore_ascii_case(Self::PARAMETER_LITERAL) {
Ok(Self::Parameter)
} else {
Err(format!(
"unknown tool strict level {value:?}; expected one of {}, {}, {}",
Self::AUTO_LITERAL,
Self::FUNCTION_LITERAL,
Self::PARAMETER_LITERAL
))
}
}
}
impl fmt::Display for ToolStrictLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Auto => Self::AUTO_LITERAL,
Self::Function => Self::FUNCTION_LITERAL,
Self::Parameter => Self::PARAMETER_LITERAL,
})
}
}
/// Validate explicit parser override names without starting request processing.
pub fn validate_parser_overrides(
tool_call_parser: &ParserSelection,
+11
View File
@@ -435,6 +435,17 @@ pub enum ChatToolChoice {
},
}
impl From<&ChatToolChoice> for xgrammar_structural_tag::ToolChoice {
fn from(tool_choice: &ChatToolChoice) -> Self {
match tool_choice {
ChatToolChoice::None => Self::none(),
ChatToolChoice::Auto => Self::auto(),
ChatToolChoice::Required => Self::required(),
ChatToolChoice::Function { name } => Self::function(name.clone()),
}
}
}
/// Resolved tool state shared by rendering, output parsing, and constraints.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResolvedToolContext {
+2 -1
View File
@@ -728,7 +728,7 @@ async fn parse_completion(
};
let mut request = base_request.clone();
let processor = backends.chat_backend.new_chat_output_processor(
let mut processor = backends.chat_backend.new_chat_output_processor(
&mut request,
NewChatOutputProcessorOptions {
tool_strict_level: vllm_chat::ToolStrictLevel::Auto,
@@ -736,6 +736,7 @@ async fn parse_completion(
reasoning_parser: &case.reasoning_parser,
},
)?;
processor.initialize(&prompt_token_ids)?;
let decoded = decoded_completion_stream(
tokenizer.as_ref(),
@@ -61,7 +61,7 @@ impl EngineCoreRequestType {
///
/// Original Python construction point:
/// <https://github.com/vllm-project/vllm/blob/cec2ec11760f9f3beabd4c90451936078bf91533/vllm/entrypoints/openai/chat_completion/serving.py#L367-L369>
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ReasoningParserKwargs {
/// Effective kwargs visible to the chat template for this request.
pub chat_template_kwargs: HashMap<String, serde_json::Value>,
@@ -62,6 +62,7 @@ fn build_request(request_id: String, max_tokens: u32) -> GenerateRequest {
session_id: None,
kv_hints: None,
reasoning_parser_kwargs: None,
reasoning_ended: None,
lora_request: None,
}
}
+8 -3
View File
@@ -54,6 +54,11 @@ pub struct GenerateRequest {
/// Optional reasoning-parser kwargs forwarded to engine-side structured
/// output logic.
pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
/// Optional engine reasoning-gate override.
///
/// `Some(true)` means the submitted grammar covers reasoning from the first
/// generated token.
pub reasoning_ended: Option<bool>,
/// Optional LoRA adapter request applied to this generation.
pub lora_request: Option<LoraRequest>,
}
@@ -84,6 +89,7 @@ impl GenerateRequest {
session_id,
kv_hints,
reasoning_parser_kwargs,
reasoning_ended,
lora_request,
} = self;
@@ -115,9 +121,7 @@ impl GenerateRequest {
session_id,
kv_hints,
external_req_id: Some(external_request_id),
// Rust parser doesn't expose this information, leave it unset and let the
// reasoning logic in engine-sided structured output manager handle it.
reasoning_ended: None,
reasoning_ended,
reasoning_parser_kwargs,
abort_immediately: false,
},
@@ -180,6 +184,7 @@ mod tests {
)]
.into(),
}),
reasoning_ended: None,
lora_request: None,
}
}
+1
View File
@@ -169,6 +169,7 @@ fn sample_generate_request(request_id: &str, max_tokens: u32) -> GenerateRequest
session_id: None,
kv_hints: None,
reasoning_parser_kwargs: None,
reasoning_ended: None,
lora_request: None,
}
}
+1
View File
@@ -11,6 +11,7 @@ test-util = []
easy-ext.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_with.workspace = true
thiserror.workspace = true
thiserror-ext.workspace = true
vllm-tokenizer.workspace = true
+1 -7
View File
@@ -3,9 +3,7 @@
use std::sync::Arc;
use vllm_parser::tool::{
Result, StructuralTagBuilder, Tool, ToolParser, ToolParserError, ToolParserOutput,
};
use vllm_parser::tool::{Result, Tool, ToolParser, ToolParserError, ToolParserOutput};
use vllm_parser::unified::{
UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput,
};
@@ -89,10 +87,6 @@ impl<T: UnifiedParser> ToolParser for UnifiedToolParserAdapter<T> {
self.inner.preserve_special_tokens()
}
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
self.inner.structural_tag_builder()
}
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
self.inner.tool_call_id(tool_index)
}
+1
View File
@@ -3,6 +3,7 @@
//! Streaming parsers for chat completions.
pub mod output_grammar;
pub mod reasoning;
pub mod tool;
pub mod unified;
+362
View File
@@ -0,0 +1,362 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Output grammar construction shared by reasoning and tool parsers.
use std::{fmt, str::FromStr};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use thiserror::Error;
use xgrammar_structural_tag::builders::{StructuralTagBuilder, StructuralTagOptions};
use xgrammar_structural_tag::format::Format;
use xgrammar_structural_tag::tool::ToolChoiceValue;
use xgrammar_structural_tag::{
FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
};
use crate::tool::Tool;
/// Result alias for output grammar construction.
pub type Result<T> = std::result::Result<T, OutputGrammarError>;
/// How much of the generated stream an output grammar covers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrammarCoverage {
/// The grammar covers reasoning and advances from the first generated token.
FromTokenZero,
/// The grammar covers only output emitted after reasoning ends.
FinalOutputOnly,
}
/// One parser-built output grammar and its generated-stream coverage.
#[derive(Debug, Clone, PartialEq)]
pub struct BuiltOutputGrammar {
/// Typed XGrammar structural-tag format.
pub format: Format,
/// Portion of the generated stream covered by `format`.
pub coverage: GrammarCoverage,
}
impl BuiltOutputGrammar {
/// A grammar that covers reasoning and everything after it.
pub fn from_token_zero(format: Format) -> Self {
Self {
format,
coverage: GrammarCoverage::FromTokenZero,
}
}
/// A grammar that covers only the output emitted after reasoning ends.
pub fn final_output_only(format: Format) -> Self {
Self {
format,
coverage: GrammarCoverage::FinalOutputOnly,
}
}
}
/// Server-side floor for tool-call structural tags (`--tool-strict-level`).
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Default,
DeserializeFromStr,
SerializeDisplay,
)]
pub enum ToolStrictLevel {
/// Derive tool constraints from the request's tool choice and per-tool strictness.
#[default]
Auto,
/// Constrain the tool-call envelope for every request with tools.
Function,
/// Additionally pin argument schemas, as if every tool were `strict: true`.
Parameter,
}
impl ToolStrictLevel {
pub const AUTO_LITERAL: &str = "auto";
pub const FUNCTION_LITERAL: &str = "function";
pub const PARAMETER_LITERAL: &str = "parameter";
}
impl FromStr for ToolStrictLevel {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
if value.eq_ignore_ascii_case(Self::AUTO_LITERAL) {
Ok(Self::Auto)
} else if value.eq_ignore_ascii_case(Self::FUNCTION_LITERAL) {
Ok(Self::Function)
} else if value.eq_ignore_ascii_case(Self::PARAMETER_LITERAL) {
Ok(Self::Parameter)
} else {
Err(format!(
"unknown tool strict level {value:?}; expected one of {}, {}, {}",
Self::AUTO_LITERAL,
Self::FUNCTION_LITERAL,
Self::PARAMETER_LITERAL
))
}
}
}
impl fmt::Display for ToolStrictLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Auto => Self::AUTO_LITERAL,
Self::Function => Self::FUNCTION_LITERAL,
Self::Parameter => Self::PARAMETER_LITERAL,
})
}
}
/// Request facts available while an initialized parser builds an output grammar.
pub struct OutputGrammarContext<'a> {
/// Effective tools available for this generation.
pub tools: &'a [Tool],
/// Effective tool-choice policy.
pub tool_choice: &'a ToolChoice,
/// Server-side floor for tool-call constraints.
pub tool_strict_level: ToolStrictLevel,
/// Whether the request permits more than one tool call.
pub parallel_tool_calls: bool,
}
/// Errors produced while building an output grammar.
#[derive(Debug, Error)]
pub enum OutputGrammarError {
/// A model-specific structural-tag builder rejected its inputs.
#[error("failed to build output grammar")]
Build(#[from] xgrammar_structural_tag::Error),
}
/// Build the visible (post-reasoning) language from a crate structural-tag
/// builder, or `None` when the request does not ask for strict tool calling.
///
/// This is the pre-existing strict-tool-calling grammar: the builder runs with
/// `reasoning = false`, so the result describes only what follows the reasoning
/// phase. It backs the default `ToolParser::build_visible_format` and the
/// native unified parsers that have not yet grown their own builder.
pub(crate) fn visible_format_from_builder(
builder: Option<&dyn StructuralTagBuilder>,
ctx: &OutputGrammarContext<'_>,
) -> Result<Option<Format>> {
let Some(builder) = builder else {
return Ok(None);
};
if !tool_grammar_applies(ctx) {
return Ok(None);
}
let structural_tag = build_structural_tag(
builder,
&tool_params(ctx.tools, ctx.tool_strict_level),
ctx.tool_choice.clone(),
StructuralTagOptions::default()
.with_reasoning(false)
.with_parallel_tool_calls(ctx.parallel_tool_calls),
)?;
Ok(Some(structural_tag.format))
}
fn tool_params(tools: &[Tool], strict_level: ToolStrictLevel) -> Vec<ToolParam> {
tools
.iter()
.map(|tool| {
ToolParam::Function(FunctionToolParam::new(FunctionDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: Some(tool.parameters.clone()),
strict: Some(
strict_level >= ToolStrictLevel::Parameter || tool.strict == Some(true),
),
}))
})
.collect()
}
/// Whether the request asks for a tool grammar at all.
fn tool_grammar_applies(ctx: &OutputGrammarContext<'_>) -> bool {
if ctx.tools.is_empty() {
return false;
}
match ctx.tool_choice {
ToolChoice::Value(ToolChoiceValue::None) => false,
ToolChoice::Value(ToolChoiceValue::Auto) => {
ctx.tool_strict_level >= ToolStrictLevel::Function
|| ctx.tools.iter().any(|tool| tool.strict == Some(true))
}
ToolChoice::Value(ToolChoiceValue::Required)
| ToolChoice::NamedFunction(_)
| ToolChoice::AllowedTools(_)
| ToolChoice::FlatAllowedTools(_)
| ToolChoice::Builtin(_) => true,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use xgrammar_structural_tag::builders::Qwen3Builder;
use super::*;
fn tool(name: &str, strict: Option<bool>) -> Tool {
Tool {
name: name.to_string(),
description: None,
parameters: json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}),
strict,
}
}
fn build(tools: &[Tool], tool_choice: &ToolChoice) -> Option<Format> {
visible_format_from_builder(
Some(&Qwen3Builder),
&OutputGrammarContext {
tools,
tool_choice,
tool_strict_level: ToolStrictLevel::Auto,
parallel_tool_calls: true,
},
)
.unwrap()
}
#[test]
fn tool_grammar_requires_tools_and_a_constraining_choice() {
let non_strict = [tool("search", None)];
assert!(build(&non_strict, &ToolChoice::none()).is_none());
assert!(build(&non_strict, &ToolChoice::auto()).is_none());
assert!(build(&non_strict, &ToolChoice::required()).is_some());
assert!(build(&non_strict, &ToolChoice::function("search")).is_some());
// `auto` only constrains once at least one tool is strict.
let strict = [tool("search", Some(true)), tool("lookup", None)];
assert!(build(&strict, &ToolChoice::auto()).is_some());
}
fn build_at_level(tool_choice: ToolChoice, tools: Vec<Tool>, level: ToolStrictLevel) -> Format {
visible_format_from_builder(
Some(&Qwen3Builder),
&OutputGrammarContext {
tools: &tools,
tool_choice: &tool_choice,
tool_strict_level: level,
parallel_tool_calls: true,
},
)
.unwrap()
.unwrap()
}
fn tag_string(format: &Format) -> String {
serde_json::to_string(format).unwrap()
}
#[test]
fn function_level_constrains_auto_without_strict_tools() {
let request = build_at_level(
ToolChoice::auto(),
vec![tool("search", None)],
ToolStrictLevel::Function,
);
assert!(tag_string(&request).contains("search"));
}
#[test]
fn absent_strict_is_non_strict() {
let unset = build_at_level(
ToolChoice::required(),
vec![tool("search", None)],
ToolStrictLevel::Auto,
);
let non_strict = build_at_level(
ToolChoice::required(),
vec![tool("search", Some(false))],
ToolStrictLevel::Auto,
);
let strict = build_at_level(
ToolChoice::required(),
vec![tool("search", Some(true))],
ToolStrictLevel::Auto,
);
assert_eq!(tag_string(&unset), tag_string(&non_strict));
assert_ne!(tag_string(&unset), tag_string(&strict));
}
#[test]
fn strict_tool_does_not_pin_its_neighbour() {
let mixed = build_at_level(
ToolChoice::auto(),
vec![tool("weather", Some(true)), tool("search", None)],
ToolStrictLevel::Auto,
);
let explicit = build_at_level(
ToolChoice::auto(),
vec![tool("weather", Some(true)), tool("search", Some(false))],
ToolStrictLevel::Auto,
);
assert_eq!(tag_string(&mixed), tag_string(&explicit));
}
#[test]
fn parameter_level_pins_argument_schemas() {
let pinned = build_at_level(
ToolChoice::auto(),
vec![tool("search", None)],
ToolStrictLevel::Parameter,
);
let strict = build_at_level(
ToolChoice::auto(),
vec![tool("search", Some(true))],
ToolStrictLevel::Auto,
);
let envelope_only = build_at_level(
ToolChoice::auto(),
vec![tool("search", None)],
ToolStrictLevel::Function,
);
assert_eq!(tag_string(&pinned), tag_string(&strict));
assert_ne!(tag_string(&pinned), tag_string(&envelope_only));
}
#[test]
fn tool_strict_level_parses_case_insensitively() {
assert_eq!("AUTO".parse::<ToolStrictLevel>(), Ok(ToolStrictLevel::Auto));
assert_eq!(
"PARAMETER".parse::<ToolStrictLevel>(),
Ok(ToolStrictLevel::Parameter)
);
assert_eq!(
"function".parse::<ToolStrictLevel>(),
Ok(ToolStrictLevel::Function)
);
assert_eq!(ToolStrictLevel::default(), ToolStrictLevel::Auto);
assert_eq!(
serde_json::to_value(ToolStrictLevel::Auto).unwrap(),
json!("auto")
);
assert_eq!(
serde_json::from_value::<ToolStrictLevel>(json!("auto")).unwrap(),
ToolStrictLevel::Auto
);
assert!("off".parse::<ToolStrictLevel>().is_err());
assert!("strict".parse::<ToolStrictLevel>().is_err());
}
}
+12
View File
@@ -31,6 +31,9 @@ mod step3p5;
use thiserror::Error;
use vllm_tokenizer::{DecodedText, DynTokenizer};
use xgrammar_structural_tag::format::Format;
use crate::output_grammar::{self, OutputGrammarContext};
pub use self::cohere_cmd::CohereCmdReasoningParser;
pub use self::deepseek_r1::DeepSeekR1ReasoningParser;
@@ -137,6 +140,15 @@ pub trait ReasoningParser: Send {
false
}
/// Prepend the reasoning phase implied by the initialized parser state.
fn wrap_visible_format(
&self,
_ctx: &OutputGrammarContext<'_>,
_visible: &Format,
) -> output_grammar::Result<Option<Format>> {
Ok(None)
}
/// Feed one decoded text delta into the parser.
fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta>;
+18
View File
@@ -39,7 +39,9 @@ pub use seed_oss::SeedOssToolParser;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use xgrammar_structural_tag::builders::StructuralTagBuilder;
use xgrammar_structural_tag::format::Format;
use crate::output_grammar::{self, OutputGrammarContext};
use crate::utils;
/// One function-style tool made available to the model.
@@ -189,7 +191,23 @@ pub trait ToolParser: Send {
false
}
/// Build the language of everything the model may emit after reasoning
/// ends: visible text plus tool calls. `None` means no tool grammar applies.
///
/// The default forwards to [`Self::structural_tag_builder`] with reasoning
/// disabled, which is exactly today's strict-tool-calling grammar.
fn build_visible_format(
&self,
ctx: &OutputGrammarContext<'_>,
) -> output_grammar::Result<Option<Format>> {
output_grammar::visible_format_from_builder(self.structural_tag_builder(), ctx)
}
/// Return the xgrammar structural-tag builder used for strict tool calling.
///
/// To be deprecated: this only exists to back the default
/// [`Self::build_visible_format`]. New parsers should override that method
/// directly instead of exposing a builder.
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
None
}
+25 -4
View File
@@ -6,8 +6,9 @@
use vllm_tokenizer::{DecodedText, DynTokenizer};
use super::{Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput};
use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
use crate::reasoning::ReasoningParser;
use crate::tool::{StructuralTagBuilder, Tool, ToolParser, ToolParserOutput};
use crate::tool::{Tool, ToolParser, ToolParserOutput};
/// Unified parser that composes existing reasoning and tool parsers.
pub struct CombinedParser {
@@ -79,8 +80,29 @@ impl UnifiedParser for CombinedParser {
|| self.tool.as_ref().is_some_and(|parser| parser.preserve_special_tokens())
}
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
self.tool.as_ref().and_then(|parser| parser.structural_tag_builder())
fn build_output_grammar(
&self,
ctx: &OutputGrammarContext<'_>,
) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
let Some(tool) = self.tool.as_ref() else {
return Ok(None);
};
let Some(visible) = tool.build_visible_format(ctx)? else {
return Ok(None);
};
// The reasoning parser may wrap that language with the reasoning phase
// implied by its initialized (prompt-derived) state. If it does, the
// grammar describes the stream from the first generated token and the
// engine can skip its own reasoning gate; if it declines, the grammar
// covers only the final output and the engine gate stays in charge.
let wrapped = match self.reasoning.as_ref() {
Some(reasoning) => reasoning.wrap_visible_format(ctx, &visible)?,
None => None,
};
Ok(Some(match wrapped {
Some(full) => BuiltOutputGrammar::from_token_zero(full),
None => BuiltOutputGrammar::final_output_only(visible),
}))
}
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
@@ -332,7 +354,6 @@ mod tests {
fn combined_parser_emits_tool_calls_from_visible_content() {
let tool = Qwen3XmlToolParser::create(&test_tools()).unwrap();
let mut parser = CombinedParser::new(None, Some(tool));
assert!(parser.structural_tag_builder().is_some());
let output = collect(
&mut parser,
+22 -25
View File
@@ -4,8 +4,9 @@
use vllm_tokenizer::{DecodedText, DynTokenizer};
use super::detect_hy_token_suffix;
use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
use crate::reasoning::HyReasoningParser;
use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, StructuralTagBuilder, Tool};
use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, Tool};
use crate::unified::{CombinedParser, Result, UnifiedParser, UnifiedParserOutput, token_id};
/// Unified reasoning and tool parser for HY3 output.
@@ -46,8 +47,11 @@ impl UnifiedParser for HyV3UnifiedParser {
self.inner.preserve_special_tokens()
}
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
self.inner.structural_tag_builder()
fn build_output_grammar(
&self,
ctx: &OutputGrammarContext<'_>,
) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
self.inner.build_output_grammar(ctx)
}
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
@@ -73,12 +77,10 @@ mod tests {
use serde_json::json;
use vllm_tokenizer::{DecodedText, Tokenizer, test_utils::TestTokenizer};
use xgrammar_structural_tag::builders::StructuralTagOptions;
use xgrammar_structural_tag::{
FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
};
use xgrammar_structural_tag::{StructuralTag, ToolChoice};
use super::{HyV3UnifiedParser, UnifiedParser};
use crate::output_grammar::{GrammarCoverage, OutputGrammarContext};
use crate::tool::Tool;
use crate::unified::{UnifiedParserEvent, UnifiedParserOutput};
@@ -151,24 +153,19 @@ mod tests {
#[test]
fn structural_tag_uses_tokenizer_detected_suffix() {
let parser = HyV3UnifiedParser::new(&tools(), Arc::new(tokenizer())).unwrap();
let structural_tools = [ToolParam::Function(FunctionToolParam::new(
FunctionDefinition::new("get_weather").with_parameters(json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
})),
))];
let tag = build_structural_tag(
parser.structural_tag_builder().unwrap(),
&structural_tools,
ToolChoice::required(),
StructuralTagOptions::default().with_reasoning(false),
)
.unwrap()
.to_json_string()
.unwrap();
let tools = tools();
let parser = HyV3UnifiedParser::new(&tools, Arc::new(tokenizer())).unwrap();
let tag = parser
.build_output_grammar(&OutputGrammarContext {
tools: &tools,
tool_strict_level: Default::default(),
parallel_tool_calls: true,
tool_choice: &ToolChoice::required(),
})
.unwrap()
.unwrap();
assert_eq!(tag.coverage, GrammarCoverage::FinalOutputOnly);
let tag = StructuralTag::new(tag.format).to_json_string().unwrap();
assert!(tag.contains("<tool_calls:opensource>"));
assert!(tag.contains("<arg_value:opensource>"));
+22 -25
View File
@@ -4,8 +4,9 @@
use vllm_tokenizer::{DecodedText, DynTokenizer};
use super::detect_hy_token_suffix;
use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
use crate::reasoning::HyReasoningParser;
use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, StructuralTagBuilder, Tool};
use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, Tool};
use crate::unified::{CombinedParser, Result, UnifiedParser, UnifiedParserOutput, token_id};
/// Unified reasoning and tool parser for HY4 output.
@@ -46,8 +47,11 @@ impl UnifiedParser for HyV4UnifiedParser {
self.inner.preserve_special_tokens()
}
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
self.inner.structural_tag_builder()
fn build_output_grammar(
&self,
ctx: &OutputGrammarContext<'_>,
) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
self.inner.build_output_grammar(ctx)
}
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
@@ -73,12 +77,10 @@ mod tests {
use serde_json::json;
use vllm_tokenizer::{DecodedText, Tokenizer, test_utils::TestTokenizer};
use xgrammar_structural_tag::builders::StructuralTagOptions;
use xgrammar_structural_tag::{
FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
};
use xgrammar_structural_tag::{StructuralTag, ToolChoice};
use super::{HyV4UnifiedParser, UnifiedParser};
use crate::output_grammar::{GrammarCoverage, OutputGrammarContext};
use crate::tool::Tool;
use crate::unified::{UnifiedParserEvent, UnifiedParserOutput};
@@ -164,24 +166,19 @@ mod tests {
#[test]
fn structural_tag_uses_compact_hy4_skeleton() {
let parser = HyV4UnifiedParser::new(&tools(), Arc::new(tokenizer())).unwrap();
let structural_tools = [ToolParam::Function(FunctionToolParam::new(
FunctionDefinition::new("get_weather").with_parameters(json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
})),
))];
let tag = build_structural_tag(
parser.structural_tag_builder().unwrap(),
&structural_tools,
ToolChoice::required(),
StructuralTagOptions::default().with_reasoning(false),
)
.unwrap()
.to_json_string()
.unwrap();
let tools = tools();
let parser = HyV4UnifiedParser::new(&tools, Arc::new(tokenizer())).unwrap();
let tag = parser
.build_output_grammar(&OutputGrammarContext {
tools: &tools,
tool_strict_level: Default::default(),
parallel_tool_calls: true,
tool_choice: &ToolChoice::required(),
})
.unwrap()
.unwrap();
assert_eq!(tag.coverage, GrammarCoverage::FinalOutputOnly);
let tag = StructuralTag::new(tag.format).to_json_string().unwrap();
assert!(tag.contains("<tool_calls:opensource>"));
assert!(tag.contains("<tool_call:opensource>get_weather"));
+12 -3
View File
@@ -51,7 +51,10 @@ use winnow::token::{literal, rest, take_till, take_until, take_while};
use self::structural_tag::KIMI_K3_STRUCTURAL_TAG_BUILDER;
use super::{Result, UnifiedParser, UnifiedParserOutput, token_id};
use crate::tool::{StructuralTagBuilder, Tool, ToolCallDelta};
use crate::output_grammar::{
self, BuiltOutputGrammar, OutputGrammarContext, visible_format_from_builder,
};
use crate::tool::{Tool, ToolCallDelta};
use crate::unified::parsing_failed;
use crate::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
@@ -275,8 +278,14 @@ impl UnifiedParser for KimiK3UnifiedParser {
true
}
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
Some(&KIMI_K3_STRUCTURAL_TAG_BUILDER)
fn build_output_grammar(
&self,
ctx: &OutputGrammarContext<'_>,
) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
Ok(
visible_format_from_builder(Some(&KIMI_K3_STRUCTURAL_TAG_BUILDER), ctx)?
.map(BuiltOutputGrammar::final_output_only),
)
}
fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
+8 -6
View File
@@ -18,10 +18,9 @@ use thiserror::Error;
use thiserror_ext::Macro;
use vllm_tokenizer::{DecodedText, DynTokenizer};
use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
use crate::reasoning::ReasoningError;
use crate::tool::{
StructuralTagBuilder, Tool, ToolCallDelta, ToolParserError, ToolParserEvent, ToolParserOutput,
};
use crate::tool::{Tool, ToolCallDelta, ToolParserError, ToolParserEvent, ToolParserOutput};
/// Result alias for unified parser operations.
pub type Result<T> = std::result::Result<T, UnifiedParserError>;
@@ -240,9 +239,12 @@ pub trait UnifiedParser: Send {
false
}
/// Return the xgrammar structural-tag builder used for strict tool calling.
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
None
/// Build the request output grammar after prompt-based initialization.
fn build_output_grammar(
&self,
_ctx: &OutputGrammarContext<'_>,
) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
Ok(None)
}
/// Return the parser-provided ID for a tool call by index, if the model emitted one.
+2 -1
View File
@@ -137,7 +137,8 @@ pub fn to_text_request(
data_parallel_rank: None,
session_id,
kv_hints,
reasoning_parser_kwargs: None,
reasoning_parser_kwargs: Default::default(),
reasoning_ended: None,
lora_request: None,
arrival_time: None,
})
@@ -83,7 +83,8 @@ pub(super) fn prepare_generate_request(
data_parallel_rank: ctx.data_parallel_rank,
session_id: ctx.session_id,
kv_hints: None,
reasoning_parser_kwargs: None,
reasoning_parser_kwargs: Default::default(),
reasoning_ended: None,
lora_request: lora_resolution.lora_request.clone(),
arrival_time: None,
};
@@ -156,7 +156,8 @@ pub(super) fn prepare_completion_request(
data_parallel_rank: ctx.data_parallel_rank,
session_id,
kv_hints: None,
reasoning_parser_kwargs: None,
reasoning_parser_kwargs: Default::default(),
reasoning_ended: None,
lora_request: lora_resolution.lora_request.clone(),
arrival_time: None,
};
+1 -1
View File
@@ -124,7 +124,7 @@ async fn render_chat(
let chat_request = lower_chat_request(body, &model_resolution(&state), request_context)?;
let (text_request, _) = state
.chat
.prepare(chat_request)
.prepare(chat_request, &state.text)
.await
.map_err(|error| ApiError::invalid_request(error.to_report_string(), None))?;
Ok(Json(lower_render_request(
+2 -1
View File
@@ -55,7 +55,8 @@ impl TokenizeCompletionRequest {
data_parallel_rank: None,
session_id: None,
kv_hints: None,
reasoning_parser_kwargs: None,
reasoning_parser_kwargs: Default::default(),
reasoning_ended: None,
lora_request: None,
arrival_time: None,
}
+21
View File
@@ -104,6 +104,27 @@ impl TextRequestProcessor {
Ok(prompt_token_ids)
}
/// Tokenize and truncate the prompt in place, so that later preparation
/// reuses the final token IDs instead of tokenizing again.
///
/// Frontends that need the final prompt tokens before submission (for
/// example, to initialize a prompt-aware output parser) call this first
/// and then hand the same request to [`TextLlm::generate`].
pub fn tokenize_in_place<'r>(&self, request: &'r mut TextRequest) -> Result<&'r [u32]> {
request.validate()?;
let prompt_token_ids = self.prepare_prompt_tokens(
take(&mut request.prompt),
request.add_special_tokens,
request.prompt_truncation.take(),
request.sampling_params.max_tokens,
)?;
request.prompt = Prompt::TokenIds(prompt_token_ids);
match &request.prompt {
Prompt::TokenIds(prompt_token_ids) => Ok(prompt_token_ids),
Prompt::Text(_) => unreachable!("prompt was just replaced with token IDs"),
}
}
/// Prepare one request's prompt tokens without generation-specific lowering.
pub fn tokenize(&self, request: TextRequest) -> Result<Vec<u32>> {
request.validate()?;
+2 -1
View File
@@ -61,7 +61,8 @@ pub fn lower_text_request(
data_parallel_rank: request.data_parallel_rank,
session_id: request.session_id.clone(),
kv_hints: request.kv_hints.clone(),
reasoning_parser_kwargs: request.reasoning_parser_kwargs.clone(),
reasoning_parser_kwargs: Some(request.reasoning_parser_kwargs.clone()),
reasoning_ended: request.reasoning_ended,
lora_request: request.lora_request.clone(),
arrival_time: request.arrival_time,
trace_headers: None,
+14 -4
View File
@@ -215,10 +215,19 @@ pub struct TextRequest {
/// Optional orchestrator-originated KV hints.
#[serde(default)]
pub kv_hints: Option<KvHintsEnvelope>,
/// Optional reasoning-parser kwargs forwarded to engine-side structured
/// output logic.
/// Reasoning-parser kwargs forwarded to engine-side structured output
/// logic. The engine consults them only when it owns grammar activation;
/// see [`Self::reasoning_ended`].
#[serde(default)]
pub reasoning_parser_kwargs: Option<ReasoningParserKwargs>,
pub reasoning_parser_kwargs: ReasoningParserKwargs,
/// Optional engine reasoning-gate override selected by a higher-level frontend.
///
/// `Some(true)` means the structured output grammar covers reasoning from
/// the first generated token, so the engine masks and advances immediately
/// instead of waiting for its reasoning parser. Unset for final-output-only
/// grammars and for requests without a grammar.
#[serde(default)]
pub reasoning_ended: Option<bool>,
/// LoRA adapter selected for this request.
#[serde(default)]
pub lora_request: Option<LoraRequest>,
@@ -247,7 +256,8 @@ impl TextRequest {
data_parallel_rank: None,
session_id: None,
kv_hints: None,
reasoning_parser_kwargs: None,
reasoning_parser_kwargs: Default::default(),
reasoning_ended: None,
lora_request: None,
arrival_time: None,
}