[Frontend] Add the Beam reasoning and tool parsers (#62052)

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
Co-authored-by: Bugen Zhao <i@bugenzhao.com>
Co-authored-by: Yoni Gozlan <74535834+yonigozlan@users.noreply.github.com>
Co-authored-by: neilkg <33635204+neilkg@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Woosuk Kwon
2026-10-16 14:08:29 -07:00
committed by GitHub
co-authored by Bugen Zhao Yoni Gozlan neilkg Cursor Claude Opus 5.5
parent 9731f30a03
commit 153a17166e
27 changed files with 4464 additions and 92 deletions
+24
View File
@@ -201,6 +201,30 @@ checkpoint's output format.
!!! note
With `tool_choice="auto"`, structural-tag constraints require both `VLLM_ENFORCE_STRICT_TOOL_CALLING=true` (the default) and at least one tool with `strict: true`, or a server-side floor set via `--tool-strict-level`. When these conditions are met and the selected parser supports structural tags, vLLM constrains the tool-call envelope and pins the argument schema of each tool that sets `strict: true` (or of every tool under `--tool-strict-level parameter`). Otherwise, vLLM extracts tool calls from raw text, so arguments may occasionally be malformed or violate the function's parameter schema.
### Beam Models (`beam`)
Beam checkpoints describe their output format with `response_template` metadata, so the `beam` parser reads it like the `hf` parser does:
```bash
vllm serve <model> \
--enable-auto-tool-choice \
--tool-call-parser beam \
--reasoning-parser beam
```
Unlike `hf`, the `beam` parser also constrains its output. For strict tools, required or named tool choice, and structured outputs, it derives a
structural-tag grammar from the template. The grammar follows the template's tool call format, pins the arguments of strict tools when their
encoding can be derived, allows a single call with `parallel_tool_calls=false`, and frames a structured output as the answer. Raw string arguments
keep the `format` or `pattern` of their schema, as XGrammar keeps them in JSON strings, unless a match could end the value early. With
`tool_choice="none"`, the grammar allows only reasoning and an answer, so a model that still sees the tools in its prompt cannot call them. When no
grammar can be derived, required and named tool choice are rejected, and strict tools under `tool_choice="auto"` are left unconstrained with a
warning.
It also constrains the output of requests without strict tools, forced tool choice, or structured outputs to the template's format: tool calls with
free arguments or an answer, or just the rest of an answer the prompt already opened. This always-on grammar needs the `xgrammar` structured
outputs backend, the default, and is disabled with `VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT=0`. From the message start, every `beam` grammar opens with
reasoning that holds visible text, unless `enable_thinking` is false.
### Hermes Models (`hermes`)
All Nous Research Hermes-series models newer than Hermes 2 Pro should be supported.
+2 -2
View File
@@ -434,7 +434,7 @@ mod tests {
)
.unwrap_err();
expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, granite4, hermes, hf, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, mimo, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_json, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: beam, deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, granite4, hermes, hf, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, mimo, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_json, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
}
#[test]
@@ -445,6 +445,6 @@ mod tests {
)
.unwrap_err();
expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, hf, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, mimo, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: beam, cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, hf, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, mimo, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
}
}
+29 -12
View File
@@ -23,7 +23,8 @@ use crate::output::{ChatOutputProcessor, DynChatEventStream, DynDecodedTextEvent
use crate::parser::reasoning::{ReasoningParser, ReasoningParserFactory};
use crate::parser::tool::{ToolParser, ToolParserFactory};
use crate::parser::unified::{
HfTemplateError, HfUnifiedParser, ResponseTemplate, UnifiedParserFactory, names,
BeamUnifiedParser, HfTemplateError, HfUnifiedParser, ResponseTemplate, UnifiedParserFactory,
names,
};
use crate::parser::{ParserSelection, ToolStrictLevel};
use crate::request::{ChatRequest, ChatTool};
@@ -206,17 +207,19 @@ impl DefaultChatOutputProcessor {
});
}
// `hf` is built from the model's template, never by its registry constructor.
let parser = if parser_name == names::HF {
let template =
response_template.as_ref().map_err(|error| Error::ParserInitialization {
kind: "unified",
name: parser_name.to_string(),
error: Box::new(error.clone()),
})?;
Box::new(HfUnifiedParser::new(template.clone(), tools, tokenizer))
} else {
factory.create(parser_name, tools, tokenizer)?
// Parsers driven by the model's template are built from it, never by
// their registry constructors.
let template = || {
response_template.clone().map_err(|error| Error::ParserInitialization {
kind: "unified",
name: parser_name.to_string(),
error: Box::new(error),
})
};
let parser: Box<dyn UnifiedParser> = match parser_name {
names::HF => Box::new(HfUnifiedParser::new(template()?, tools, tokenizer)),
names::BEAM => Box::new(BeamUnifiedParser::new(template()?, tools, tokenizer)),
_ => factory.create(parser_name, tools, tokenizer)?,
};
UNIFIED_PARSER_LOG_ONCE.call_once(|| info!(parser_name, "using unified parser"));
@@ -589,6 +592,20 @@ mod tests {
.assert_eq(&error(invalid_template()));
}
#[test]
fn explicit_beam_uses_the_model_response_template() {
let beam = ParserSelection::Explicit("beam".to_string());
crate::parser::validate_parser_overrides(&beam, &beam).unwrap();
assert!(keeps_special_tokens("other-model", &loaded_template(), &beam, &beam).unwrap());
let error =
keeps_special_tokens("other-model", &Err(HfTemplateError::Missing), &beam, &beam)
.unwrap_err()
.to_report_string();
expect_test::expect!["failed to initialize unified parser `beam`: the model's tokenizer_config.json provides no response_template"]
.assert_eq(&error);
}
#[test]
fn auto_never_selects_hf() {
let auto = ParserSelection::Auto;
+6 -2
View File
@@ -6,8 +6,8 @@
use std::sync::LazyLock;
pub use vllm_parser::unified::{
Gemma4UnifiedParser, HfTemplateError, HfUnifiedParser, HyV3UnifiedParser, HyV4UnifiedParser,
InklingUnifiedParser, KimiK3UnifiedParser, ResponseTemplate, UnifiedParser,
BeamUnifiedParser, Gemma4UnifiedParser, HfTemplateError, HfUnifiedParser, HyV3UnifiedParser,
HyV4UnifiedParser, InklingUnifiedParser, KimiK3UnifiedParser, ResponseTemplate, UnifiedParser,
};
use vllm_tokenizer::DynTokenizer;
@@ -26,6 +26,9 @@ pub mod names {
/// name resolution only: it is built from the model's template, never by its
/// registry constructor.
pub const HF: &str = "hf";
/// Beam checkpoints: [`HF`] with Beam's tool-call grammar, built from the
/// model's template the same way.
pub const BEAM: &str = "beam";
}
/// Constructor signature for one registered unified parser implementation.
@@ -55,6 +58,7 @@ impl UnifiedParserFactory {
factory.register_parser::<KimiK3UnifiedParser>(names::KIMI_K3);
// Opt-in only: no model patterns map to it.
factory.register_parser::<HfUnifiedParser>(names::HF);
factory.register_parser::<BeamUnifiedParser>(names::BEAM);
factory
.register_pattern("gemma-4", names::GEMMA4)
+3 -2
View File
@@ -207,7 +207,8 @@ pub(crate) fn full_format_from_builder_for_test(
)
}
fn tool_params(tools: &[Tool], strict_level: ToolStrictLevel) -> Vec<ToolParam> {
/// Tools as builder inputs, strict when the tool or the server floor asks for it.
pub(crate) fn tool_params(tools: &[Tool], strict_level: ToolStrictLevel) -> Vec<ToolParam> {
tools
.iter()
.map(|tool| {
@@ -240,7 +241,7 @@ pub(crate) fn normalize_tool_choice(
}
/// Whether the request asks for a tool grammar at all.
fn tool_grammar_applies(ctx: &OutputGrammarContext<'_>) -> bool {
pub(crate) fn tool_grammar_applies(ctx: &OutputGrammarContext<'_>) -> bool {
if ctx.tools.is_empty() {
return false;
}
+77 -20
View File
@@ -26,7 +26,7 @@
//! - `allOf` with more than one schema accepts any value.
use serde_json::{Value, json};
use xgrammar_structural_tag::format::{Format, JsonSchemaFormat};
use xgrammar_structural_tag::format::{EndBoundary, Format, JsonSchemaFormat, TokenFormat};
pub use crate::schema::JsonType;
use crate::schema::{self, OptionSource, SchemaOption, SchemaRoot};
@@ -60,7 +60,13 @@ impl ParameterKey<'_> {
/// `prefix KEY suffix content end`: a tag for a declared key, or a
/// sequence matching [`Self::FREE_PATTERN`] for a free key.
pub fn tag(&self, prefix: &str, suffix: &str, content: Format, end: &str) -> Format {
pub fn tag(
&self,
prefix: &str,
suffix: &str,
content: Format,
end: impl Into<EndBoundary>,
) -> Format {
match self {
Self::Declared(key) => Format::tag(format!("{prefix}{key}{suffix}"), content, end),
Self::Free => Format::sequence(vec![
@@ -68,7 +74,13 @@ impl ParameterKey<'_> {
Format::regex(Self::FREE_PATTERN),
Format::const_string(suffix),
content,
Format::const_string(end),
match end.into() {
EndBoundary::Text(end) => Format::const_string(end),
EndBoundary::Texts(ends) => {
one_of(ends.into_iter().map(Format::const_string).collect())
}
EndBoundary::Token(end) => Format::Token(TokenFormat { token: end.token }),
},
]),
}
}
@@ -183,6 +195,36 @@ impl ArgumentContext<'_> {
/// Build the grammar of one call's arguments under `schema`, the tool's
/// parameters schema, rendering each parameter with `syntax`.
pub fn arguments(schema: &Value, syntax: &dyn ArgumentSyntax, options: &ArgumentOptions) -> Format {
parameter_list(
options,
syntax.separator(),
parameters(schema, syntax, options),
)
}
/// The parameters of one call, each rendered by an [`ArgumentSyntax`], for a
/// syntax that lists them itself rather than through [`arguments`].
pub struct Parameters {
/// The declared parameters, in declared order.
pub declared: Vec<Parameter>,
/// Any one additional parameter, if the schema allows them.
pub additional: Option<Format>,
}
/// One declared parameter.
pub struct Parameter {
pub key: String,
pub format: Format,
pub required: bool,
}
/// The parameters of one call under `schema`, the tool's parameters schema,
/// rendering each parameter with `syntax`.
pub fn parameters(
schema: &Value,
syntax: &dyn ArgumentSyntax,
options: &ArgumentOptions,
) -> Parameters {
let cx = ArgumentContext {
options,
root: SchemaRoot::new(schema),
@@ -190,8 +232,7 @@ pub fn arguments(schema: &Value, syntax: &dyn ArgumentSyntax, options: &Argument
let parameter =
|key: ParameterKey<'_>, schema: &Value| syntax.parameter(key, &cx.value_options(schema));
let schema = cx.root.resolved();
let (properties, additional) = match schema {
match cx.root.resolved() {
Value::Object(schema) => {
let additional = match schema.get("additionalProperties") {
Some(Value::Bool(false)) => None,
@@ -206,22 +247,33 @@ pub fn arguments(schema: &Value, syntax: &dyn ArgumentSyntax, options: &Argument
.and_then(Value::as_array)
.map(|required| required.iter().filter_map(Value::as_str).collect::<Vec<_>>())
.unwrap_or_default();
let properties = schema
let declared = schema
.get("properties")
.and_then(Value::as_object)
.into_iter()
.flatten()
.filter_map(|(key, schema)| {
let parameter = parameter(ParameterKey::Declared(key), schema)?;
Some((parameter, required.contains(&key.as_str())))
Some(Parameter {
key: key.clone(),
format: parameter(ParameterKey::Declared(key), schema)?,
required: required.contains(&key.as_str()),
})
})
.collect();
(properties, additional)
Parameters {
declared,
additional,
}
}
Value::Bool(false) => (vec![], None),
_ => (vec![], parameter(ParameterKey::Free, &ANY_SCHEMA)),
};
parameter_list(options, syntax.separator(), properties, additional)
Value::Bool(false) => Parameters {
declared: vec![],
additional: None,
},
_ => Parameters {
declared: vec![],
additional: parameter(ParameterKey::Free, &ANY_SCHEMA),
},
}
}
/// Parameters in declared order, required ones mandatory, followed by any
@@ -230,8 +282,10 @@ pub fn arguments(schema: &Value, syntax: &dyn ArgumentSyntax, options: &Argument
fn parameter_list(
options: &ArgumentOptions,
separator: Option<Format>,
properties: Vec<(Format, bool)>,
additional: Option<Format>,
Parameters {
declared,
additional,
}: Parameters,
) -> Format {
let separated = |parameter: Format| match &separator {
Some(separator) => Format::sequence(vec![parameter, separator.clone()]),
@@ -239,10 +293,10 @@ fn parameter_list(
};
let mut elements = Vec::from_iter(separator.clone());
if options.any_order {
let any_required = properties.iter().any(|(_, required)| *required);
let parameters = properties
let any_required = declared.iter().any(|parameter| parameter.required);
let parameters = declared
.into_iter()
.map(|(parameter, _)| parameter)
.map(|parameter| parameter.format)
.chain(additional)
.collect::<Vec<_>>();
if !parameters.is_empty() {
@@ -254,8 +308,11 @@ fn parameter_list(
});
}
} else {
for (parameter, required) in properties {
let parameter = separated(parameter);
for Parameter {
format, required, ..
} in declared
{
let parameter = separated(format);
elements.push(if required {
parameter
} else {
+543
View File
@@ -0,0 +1,543 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Unified parser for Beam checkpoints.
//!
//! Beam output is parsed by [`HfUnifiedParser`] with the checkpoint's
//! `response_template`; this parser adds Beam's output grammar.
//! A Beam message after `<|start|>assistant` is:
//!
//! ```text
//! <|channel|>analysis<|message|>REASONING<|end|><|start|>assistant
//! ( <|channel|>final<|message|>ANSWER<|return|>
//! | CALL (<|end|><|start|>assistant CALL)* <|call|> )
//! ```
//!
//! where reasoning may end at any closer of the template's reasoning field,
//! and `CALL` is
//! ` to=NAME<|channel|>analysis <|constrain|>xml<|message|>` followed by
//! `<rfl:parameter name="KEY">\nVALUE\n</rfl:parameter>` parameters separated
//! by newlines. Where the template looks ahead for the next parameter, a value
//! may also end on a newline right before it.
//!
//! The grammar enforces the schema of every declared tool's arguments, in
//! declared or alphabetical order, since Beam drifts from schemas it does not
//! enforce. When the model may answer instead, calls may also name any other
//! tool with any arguments, which the parser passes on so a harness can tell
//! the model the tool does not exist. `ANSWER` follows the request's structured
//! output, if any.
//!
//! The chat template rejects a history turn without reasoning, so a message the
//! model opens holds non-empty reasoning: an empty one would come back as null
//! reasoning and fail the next request.
//!
//! Unless `VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT` turns it off, the grammar
//! applies to every message, including one the request does not constrain.
use std::collections::BTreeMap;
use std::sync::{Arc, LazyLock};
use serde_json::Value;
use vllm_tokenizer::{DecodedText, DynTokenizer};
use xgrammar_structural_tag::ToolChoice;
use xgrammar_structural_tag::format::{EndBoundary, Format};
use xgrammar_structural_tag::tool::{
BuilderToolChoice, NormalizedToolChoice, ToolChoiceValue, normalize_tool_choice,
};
use super::{
HfPosition, HfUnifiedParser, ResponseTemplate, Result, UnifiedParser, UnifiedParserError,
UnifiedParserOutput,
};
use crate::output_grammar::arguments::{
self, ArgumentOptions, ArgumentSyntax, JsonType, ParameterKey, Parameters, ValueOption, one_of,
};
use crate::output_grammar::{
self, BuiltOutputGrammar, OutputGrammarContext, tool_grammar_applies, tool_params,
};
use crate::tool::Tool;
const ANALYSIS_OPEN: &str = "<|channel|>analysis<|message|>";
const FINAL_OPEN: &str = "<|channel|>final<|message|>";
const END: &str = "<|end|>";
/// Closes one message and opens the next assistant message.
const NEXT_MESSAGE: &str = "<|end|><|start|>assistant";
const RETURN: &str = "<|return|>";
const CALL: &str = "<|call|>";
/// Follows ` to=NAME` in a call.
const CALL_HEADER_END: &str = "<|channel|>analysis <|constrain|>xml<|message|>";
const PARAMETER_OPEN: &str = "<rfl:parameter name=\"";
const PARAMETER_NAME_CLOSE: &str = "\">\n";
const PARAMETER_CLOSE: &str = "\n</rfl:parameter>";
/// Ends a value where the template looks ahead for the next parameter.
const NEXT_PARAMETER: &str = "\n<rfl:parameter name=\"";
/// Ends of a lenient value that another parameter follows: the closer and
/// separator before the next opener, or the opener right after the value.
const NEXT_PARAMETER_ENDS: [&str; 3] = [
"\n</rfl:parameter><rfl:parameter name=\"",
"\n</rfl:parameter>\n<rfl:parameter name=\"",
NEXT_PARAMETER,
];
/// Text that ends the call in the parser, even inside a JSON value.
const CALL_TERMINATORS: [&str; 2] = [END, CALL];
/// Whether every Beam message follows the grammar, even when the request
/// constrains neither tool calls nor the answer. Read from
/// `VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT` like the Python frontend: on unless
/// set to something other than `true` or `1`.
static ALWAYS_CONSTRAIN_OUTPUT: LazyLock<bool> = LazyLock::new(|| {
std::env::var("VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT").map_or(true, |value| {
matches!(value.to_lowercase().as_str(), "true" | "1")
})
});
/// Beam parser: [`HfUnifiedParser`] with Beam's output grammar.
pub struct BeamUnifiedParser {
inner: HfUnifiedParser,
/// Whether a value may end where the next parameter opens.
lenient: bool,
/// What ends the reasoning and opens the next message.
reasoning_ends: Vec<String>,
}
impl BeamUnifiedParser {
/// Create a parser for one request from the checkpoint's `response_template`.
pub fn new(template: Arc<ResponseTemplate>, tools: &[Tool], tokenizer: DynTokenizer) -> Self {
Self {
lenient: template.argument_lookahead() == Some(PARAMETER_OPEN),
reasoning_ends: match template.reasoning_closers() {
[] => vec![NEXT_MESSAGE.to_string()],
closers => closers.to_vec(),
},
inner: HfUnifiedParser::new(template, tools, tokenizer),
}
}
}
impl UnifiedParser for BeamUnifiedParser {
fn create(_tools: &[Tool], _tokenizer: DynTokenizer) -> Result<Box<dyn UnifiedParser>> {
Err(UnifiedParserError::NoNamedConstructor {
parser: "beam",
built_from: "the checkpoint's response_template",
})
}
fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
self.inner.initialize(prompt_token_ids)
}
fn preserve_special_tokens(&self) -> bool {
self.inner.preserve_special_tokens()
}
fn build_output_grammar(
&self,
ctx: &OutputGrammarContext<'_>,
) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
build_output_grammar(
self.inner.position(),
ctx,
*ALWAYS_CONSTRAIN_OUTPUT,
self.lenient,
&self.reasoning_ends,
)
}
fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
self.inner.tool_call_id(tool_index)
}
fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
self.inner.parse_into(delta, output)
}
fn finish(&mut self) -> Result<UnifiedParserOutput> {
self.inner.finish()
}
fn reset(&mut self) -> String {
self.inner.reset()
}
}
/// The grammar of a Beam message from the first generated token, or `None`
/// when the parser is neither at the start of the message nor inside its
/// reasoning, or when the request constrains nothing and `always_constrain` is
/// off. Values may end where the next parameter opens if `lenient`, and
/// reasoning ends at any of `reasoning_ends`.
fn build_output_grammar(
position: HfPosition,
ctx: &OutputGrammarContext<'_>,
always_constrain: bool,
lenient: bool,
reasoning_ends: &[String],
) -> output_grammar::Result<Option<BuiltOutputGrammar>> {
let forced = !ctx.tools.is_empty()
&& matches!(
ctx.tool_choice,
ToolChoice::Value(ToolChoiceValue::Required) | ToolChoice::NamedFunction(_)
);
let answer_format = ctx.answer.filter(|_| !forced);
// The prompt may still list the tools, so only the grammar keeps the model
// from calling them.
let tools_forbidden = !ctx.tools.is_empty()
&& matches!(ctx.tool_choice, ToolChoice::Value(ToolChoiceValue::None));
if !(always_constrain
|| tool_grammar_applies(ctx)
|| tools_forbidden
|| answer_format.is_some())
{
return Ok(None);
}
let text = Format::any_text();
let reasoning_end = EndBoundary::from(reasoning_ends.to_vec());
let reasoning = match position {
HfPosition::ResponseStart => Format::tag(
ANALYSIS_OPEN,
// At least one character, which cannot start Beam's closers.
Format::sequence(vec![Format::regex("[^<]"), text]),
reasoning_end,
),
HfPosition::Reasoning => Format::tag("", text, reasoning_end),
// Inside an answer the prompt opened, only the always-on grammar
// applies, finishing a free answer. A tool choice that forces a call
// cannot be met there, and the request's own constraint applies as is.
HfPosition::Content => {
return Ok(
(always_constrain && !forced && answer_format.is_none()).then(|| {
BuiltOutputGrammar::from_token_zero(Format::sequence(answer_rest(None)))
}),
);
}
HfPosition::Other => return Ok(None),
};
let NormalizedToolChoice {
function_tools,
choice,
..
} = normalize_tool_choice(
&tool_params(ctx.tools, ctx.tool_strict_level),
ctx.tool_choice.clone(),
)?;
if function_tools.is_empty() {
return Ok(match choice {
// `none`, or no tools to call: the model can only answer.
BuilderToolChoice::Auto => {
Some(BuiltOutputGrammar::from_token_zero(Format::sequence(vec![
reasoning,
answer(answer_format),
])))
}
BuilderToolChoice::Required | BuilderToolChoice::Forced => None,
});
}
let mut calls = function_tools
.iter()
.map(|tool| {
let header =
Format::const_string(format!(" to={}{CALL_HEADER_END}", tool.function.name));
let schema = tool.function.parameters.clone().unwrap_or(Value::Bool(true));
Format::sequence(vec![header, arguments(&schema, lenient)])
})
.collect::<Vec<_>>();
if matches!(choice, BuilderToolChoice::Auto) {
let names = ctx.tools.iter().map(|tool| tool.name.as_str()).collect::<Vec<_>>();
let header = Format::sequence(vec![
Format::const_string(" to="),
Format::regex(other_names(&names)),
Format::const_string(CALL_HEADER_END),
]);
calls.push(Format::sequence(vec![
header,
arguments(&Value::Bool(true), lenient),
]));
}
let call = one_of(calls);
let mut calls = vec![call.clone()];
if ctx.parallel_tool_calls && !choice.is_forced() {
calls.push(Format::star(Format::sequence(vec![
Format::const_string(NEXT_MESSAGE),
call,
])));
}
// The last call ends with the `<|call|>` stop token.
calls.push(Format::optional(Format::const_string(CALL)));
let calls = Format::sequence(calls);
let output = match choice {
BuilderToolChoice::Auto => Format::or(vec![calls, answer(answer_format)]),
BuilderToolChoice::Required | BuilderToolChoice::Forced => calls,
};
Ok(Some(BuiltOutputGrammar::from_token_zero(Format::sequence(
vec![reasoning, output],
))))
}
/// A final answer, ending with the `<|return|>` stop token.
fn answer(format: Option<&Format>) -> Format {
Format::sequence([vec![Format::const_string(FINAL_OPEN)], answer_rest(format)].concat())
}
/// The rest of an open final answer, holding `format` or else free text.
fn answer_rest(format: Option<&Format>) -> Vec<Format> {
vec![
// Without `<|end|>`, no unconstrained message can follow the answer.
format.map_or_else(|| Format::any_text_excluding(&[END, RETURN]), Format::clone),
Format::optional(Format::const_string(RETURN)),
]
}
/// The arguments under `schema`: its declared parameters in declared or
/// alphabetical order, required ones mandatory, then any additional ones. If
/// `lenient`, each value but the last may end by opening the next parameter.
fn arguments(schema: &Value, lenient: bool) -> Format {
let options = ArgumentOptions {
excludes: CALL_TERMINATORS.map(str::to_string).to_vec(),
..ArgumentOptions::default()
};
let render = |syntax: BeamArguments| arguments::parameters(schema, &syntax, &options);
if lenient {
lenient_arguments(render(BeamArguments::Joined), render(BeamArguments::Last))
} else {
closed_arguments(render(BeamArguments::Closed))
}
}
/// Arguments whose values all end with a closer.
fn closed_arguments(
Parameters {
declared,
additional,
}: Parameters,
) -> Format {
let additional = additional.map(Format::star);
let lists = orders(&declared, |parameter| &parameter.key)
.into_iter()
.map(|order| {
let listed = order
.into_iter()
.map(|parameter| listed(parameter.format.clone(), parameter.required));
listed.chain(additional.clone()).collect::<Vec<_>>()
})
.filter(|list| !list.is_empty())
.map(Format::sequence)
.collect::<Vec<_>>();
if lists.is_empty() {
Format::const_string("")
} else {
one_of(lists)
}
}
/// A declared parameter as one another parameter follows, `joined`, and as the
/// last one.
struct LenientParameter {
key: String,
joined: Format,
last: Format,
required: bool,
}
impl LenientParameter {
fn joined(&self) -> Format {
listed(self.joined.clone(), self.required)
}
}
/// Arguments whose values end by opening the next parameter, but for the last
/// one, from the parameters as [`BeamArguments::Joined`] and as
/// [`BeamArguments::Last`].
fn lenient_arguments(joined: Parameters, last: Parameters) -> Format {
// Both forms share their terminators, so they keep the same parameters.
let declared = joined
.declared
.into_iter()
.zip(last.declared)
.map(|(joined, last)| LenientParameter {
key: joined.key,
joined: joined.format,
last: last.format,
required: joined.required,
})
.collect::<Vec<_>>();
let additional = joined.additional.zip(last.additional);
let mut lists = vec![];
for order in orders(&declared, |parameter| &parameter.key) {
// The last parameter is a declared one that follows every required
// one, or an additional one.
if !order.is_empty() {
let first = order.iter().rposition(|parameter| parameter.required).unwrap_or(0);
let mut list =
order[..first].iter().map(|parameter| parameter.joined()).collect::<Vec<_>>();
list.push(last_present(&order[first..]));
lists.push(Format::sequence(list));
}
if let Some((joined, last)) = &additional {
let mut list = order.iter().map(|parameter| parameter.joined()).collect::<Vec<_>>();
list.extend([Format::star(joined.clone()), last.clone()]);
lists.push(Format::sequence(list));
}
}
if lists.is_empty() {
return Format::const_string("");
}
// Each parameter starts past the opener, as the value before writes it.
let arguments = Format::sequence(vec![
separator(),
Format::const_string(PARAMETER_OPEN),
one_of(lists),
]);
if declared.iter().any(|parameter| parameter.required) {
arguments
} else {
Format::optional(arguments)
}
}
/// `parameters`, at least one, in order and the last present one in its last
/// form. Only the first may be required.
fn last_present(parameters: &[&LenientParameter]) -> Format {
if let [only] = parameters {
return only.last.clone();
}
// The last one is in the first half, or after all of it. Halving keeps the
// grammar O(n log n) in size and O(log n) deep.
let (head, tail) = parameters.split_at(parameters.len() / 2);
let mut after_head = head.iter().map(|parameter| parameter.joined()).collect::<Vec<_>>();
after_head.push(last_present(tail));
Format::or(vec![last_present(head), Format::sequence(after_head)])
}
/// `parameters` in declared order, and in alphabetical order by `key` if that
/// differs, since Beam often writes them sorted.
fn orders<P>(parameters: &[P], key: impl Fn(&P) -> &str) -> Vec<Vec<&P>> {
let declared = parameters.iter().collect::<Vec<_>>();
let mut sorted = declared.clone();
sorted.sort_by_key(|parameter| key(parameter));
if sorted
.iter()
.map(|parameter| key(parameter))
.eq(declared.iter().map(|parameter| key(parameter)))
{
vec![declared]
} else {
vec![declared, sorted]
}
}
/// `parameter` as listed: optional unless `required`.
fn listed(parameter: Format, required: bool) -> Format {
if required {
parameter
} else {
Format::optional(parameter)
}
}
fn separator() -> Format {
Format::optional(Format::const_string("\n"))
}
/// A regex for the tool names the call header can spell (no whitespace or
/// `<`) besides `names`.
fn other_names(names: &[&str]) -> String {
#[derive(Default)]
struct Node {
children: BTreeMap<char, Node>,
end: bool,
}
fn escape(char: char, meta: &str) -> String {
if meta.contains(char) {
format!("\\{char}")
} else {
char.to_string()
}
}
/// A name character other than `chars`.
fn other<'a>(chars: impl Iterator<Item = &'a char>) -> String {
let chars = chars.map(|&char| escape(char, r"\]^-[")).collect::<String>();
format!(r"[^\s<{chars}]")
}
/// The rest of a name past `node` that ends on none of `names`.
fn rest(node: &Node, first: bool) -> String {
let any = other([].iter());
let mut branches = vec![format!("{}{any}*", other(node.children.keys()))];
branches.extend(node.children.iter().map(|(&char, child)| {
format!("{}{}", escape(char, r".^$*+?{}[]\|()"), rest(child, false))
}));
let optional = if first || node.end { "" } else { "?" };
format!("(?:{}){optional}", branches.join("|"))
}
let mut root = Node::default();
for name in names {
let mut node = &mut root;
for char in name.chars() {
node = node.children.entry(char).or_default();
}
node.end = true;
}
rest(&root, true)
}
/// How one parameter is written: `<rfl:parameter name="KEY">\nVALUE` followed
/// by its end.
///
/// The parser reads every value as text and converts it by the tool schema, so
/// string options are raw text and every other option is JSON.
enum BeamArguments {
/// The whole parameter, ending with `\n</rfl:parameter>`, after an optional
/// newline.
Closed,
/// The parameter past its opener, where the template looks ahead for the
/// next one: it ends by opening the next parameter, also right after the
/// value's newline, so raw text holds no such opener.
Joined,
/// The last parameter past its opener under such a template, ending with
/// `\n</rfl:parameter>`.
Last,
}
impl ArgumentSyntax for BeamArguments {
fn separator(&self) -> Option<Format> {
None
}
fn parameter(&self, key: ParameterKey<'_>, options: &[ValueOption<'_>]) -> Option<Format> {
let terminators: &[&str] = match self {
Self::Closed => &["</rfl:parameter>"],
Self::Joined | Self::Last => &["</rfl:parameter>", NEXT_PARAMETER],
};
let values = options
.iter()
.filter_map(|option| match option.ty {
JsonType::String => option.raw_string(terminators),
_ => Some(option.json()),
})
.collect::<Vec<_>>();
(!values.is_empty()).then(|| {
let value = one_of(values);
match self {
Self::Closed => Format::sequence(vec![
separator(),
key.tag(PARAMETER_OPEN, PARAMETER_NAME_CLOSE, value, PARAMETER_CLOSE),
]),
Self::Joined => key.tag(
"",
PARAMETER_NAME_CLOSE,
value,
NEXT_PARAMETER_ENDS.to_vec(),
),
Self::Last => key.tag("", PARAMETER_NAME_CLOSE, value, PARAMETER_CLOSE),
}
})
}
}
#[cfg(test)]
mod tests;
+897
View File
@@ -0,0 +1,897 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use std::sync::Arc;
use serde_json::{Value, json};
use vllm_tokenizer::test_utils::TestTokenizer;
use vllm_tokenizer::{DecodedText, Tokenizer as _};
use xgrammar_structural_tag::ToolChoice;
use xgrammar_structural_tag::format::Format;
use super::{BeamUnifiedParser, HfPosition, build_output_grammar, other_names};
use crate::output_grammar::test_utils::outline;
use crate::output_grammar::{GrammarCoverage, OutputGrammarContext, ToolStrictLevel};
use crate::tool::Tool;
use crate::unified::{ResponseTemplate, UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
/// The `tag_pattern` of the checkpoint of [`template`].
const TAG_PATTERN: &str =
r#"<rfl:parameter name="(?P<key>[^"]+)">\n(?P<value>.*?)\n</rfl:parameter>"#;
/// [`TAG_PATTERN`], but a value may also end where the next parameter opens.
const LENIENT_TAG_PATTERN: &str = r#"<rfl:parameter name="(?P<key>[^"]+)">\n(?P<value>.*?)\n(?:</rfl:parameter>|(?=<rfl:parameter name="))"#;
/// The Beam-1.0 `response_template`, with `tag_pattern`.
fn template(tag_pattern: &str) -> Arc<ResponseTemplate> {
template_with(tag_pattern, json!("<|end|><|start|>assistant"))
}
/// [`template`] with `thinking_close` as the reasoning closer.
fn template_with(tag_pattern: &str, thinking_close: Value) -> Arc<ResponseTemplate> {
let template = json!({
"defaults": {"role": "assistant"},
"start_anchor_pattern": r"<\|start\|>assistant",
"fields": {
"thinking": {
"open": "<|channel|>analysis<|message|>",
"close": thinking_close,
"content": "text"
},
"content": {
"open": "<|channel|>final<|message|>",
"close": ["<|return|>", "<|end|>", "<|call|>"],
"content": "text"
},
"tool_calls": {
"open_pattern": r" to=(?P<name>[^\s<]+)<\|channel\|>analysis <\|constrain\|>xml<\|message\|>",
"close": ["<|end|><|start|>assistant", "<|call|>"],
"repeats": true,
"content": "xml-inline",
"content_args": {
"tag_pattern": tag_pattern,
"value_parser": {"name": "text", "args": {"strip": false}}
},
"transform": {
"type": "function",
"function": {"name": "{name}", "arguments": "{content}"}
}
}
}
});
Arc::new(ResponseTemplate::from_json(&template).unwrap())
}
fn tools() -> Vec<Tool> {
vec![
Tool {
name: "get_weather".to_string(),
description: None,
parameters: json!({
"type": "object",
"properties": {
"location": {"type": "string"},
"days": {"type": "integer"},
"unit": {"enum": ["c", "f"]}
},
"required": ["location"],
"additionalProperties": false
}),
strict: Some(true),
defer_loading: None,
},
Tool {
name: "get_time".to_string(),
description: None,
parameters: json!({
"type": "object",
"properties": {"zone": {"type": "string"}}
}),
strict: None,
defer_loading: None,
},
]
}
/// A parser initialized from `prompt`, the rendered conversation.
fn parser(prompt: &str) -> BeamUnifiedParser {
parser_with(TAG_PATTERN, prompt)
}
fn parser_with(tag_pattern: &str, prompt: &str) -> BeamUnifiedParser {
let tokenizer = Arc::new(TestTokenizer::new());
let prompt_ids = tokenizer.encode(prompt, false).unwrap();
let mut parser = BeamUnifiedParser::new(template(tag_pattern), &tools(), tokenizer);
parser.initialize(&prompt_ids).unwrap();
parser
}
const PROMPT: &str = "<|start|>user<|message|>Weather in Paris?<|end|><|start|>assistant";
fn grammar(position: HfPosition, tool_choice: ToolChoice, parallel: bool) -> Option<String> {
grammar_with(position, &tools(), tool_choice, parallel, false, false)
}
fn grammar_with(
position: HfPosition,
tools: &[Tool],
tool_choice: ToolChoice,
parallel: bool,
always_constrain: bool,
lenient: bool,
) -> Option<String> {
let ctx = OutputGrammarContext {
tools,
tool_choice: &tool_choice,
tool_strict_level: ToolStrictLevel::Auto,
parallel_tool_calls: parallel,
answer: None,
};
grammar_in(position, &ctx, always_constrain, lenient)
}
fn grammar_in(
position: HfPosition,
ctx: &OutputGrammarContext<'_>,
always_constrain: bool,
lenient: bool,
) -> Option<String> {
let grammar = build_output_grammar(
position,
ctx,
always_constrain,
lenient,
template(TAG_PATTERN).reasoning_closers(),
)
.unwrap()?;
assert_eq!(grammar.coverage, GrammarCoverage::FromTokenZero);
Some(outline(&grammar.format))
}
fn constrained_grammar(
position: HfPosition,
tool_choice: ToolChoice,
answer: Format,
always_constrain: bool,
) -> Option<String> {
let tools = tools();
let ctx = OutputGrammarContext {
tools: &tools,
tool_choice: &tool_choice,
tool_strict_level: ToolStrictLevel::Auto,
parallel_tool_calls: true,
answer: Some(&answer),
};
grammar_in(position, &ctx, always_constrain, false)
}
#[test]
fn position_follows_the_prompt_and_then_the_output() {
let position = |suffix: &str| parser(&format!("{PROMPT}{suffix}")).inner.position();
assert_eq!(position(""), HfPosition::ResponseStart);
assert_eq!(
position("<|channel|>analysis<|message|>Let me"),
HfPosition::Reasoning
);
// The reasoning closer ends with the anchor, so the next message starts.
assert_eq!(
position("<|channel|>analysis<|message|>Done.<|end|><|start|>assistant"),
HfPosition::ResponseStart
);
// A partial closer stays buffered.
assert_eq!(
position("<|channel|>analysis<|message|>Done.<|end|>"),
HfPosition::Other
);
assert_eq!(
position("<|channel|>final<|message|>It is"),
HfPosition::Content
);
// A partial boundary stays buffered.
assert_eq!(position("<|channel|>ana"), HfPosition::Other);
// Without the anchor, the parser parses the output from the start.
assert_eq!(
parser("no anchor").inner.position(),
HfPosition::ResponseStart
);
let mut parser = parser(PROMPT);
parser
.parse_into(
DecodedText::unattributed("<|channel|>analysis<|message|>Hmm"),
&mut UnifiedParserOutput::default(),
)
.unwrap();
assert_eq!(parser.inner.position(), HfPosition::Reasoning);
}
#[test]
fn required_calls_any_tool_after_reasoning() {
expect_test::expect![[r#"
sequence
tag `<|channel|>analysis<|message|>` .. `<|end|><|start|>assistant`
sequence
/[^<]/
text
sequence
or
sequence
` to=get_weather<|channel|>analysis <|constrain|>xml<|message|>`
or
sequence
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
` to=get_time<|channel|>analysis <|constrain|>xml<|message|>`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="zone">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
star
sequence
`<|end|><|start|>assistant`
or
sequence
` to=get_weather<|channel|>analysis <|constrain|>xml<|message|>`
or
sequence
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
` to=get_time<|channel|>analysis <|constrain|>xml<|message|>`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="zone">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional `<|call|>`
"#]].assert_eq(
&grammar(HfPosition::ResponseStart, ToolChoice::required(), true).unwrap(),
);
}
#[test]
fn auto_with_a_strict_tool_also_allows_an_answer() {
expect_test::expect![[r#"
sequence
tag `<|channel|>analysis<|message|>` .. `<|end|><|start|>assistant`
sequence
/[^<]/
text
or
sequence
or
sequence
` to=get_weather<|channel|>analysis <|constrain|>xml<|message|>`
or
sequence
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
` to=get_time<|channel|>analysis <|constrain|>xml<|message|>`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="zone">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
sequence
sequence
` to=`
/(?:[^\s<g][^\s<]*|g(?:[^\s<e][^\s<]*|e(?:[^\s<t][^\s<]*|t(?:[^\s<_][^\s<]*|_(?:[^\s<tw][^\s<]*|t(?:[^\s<i][^\s<]*|i(?:[^\s<m][^\s<]*|m(?:[^\s<e][^\s<]*|e(?:[^\s<][^\s<]*))?)?)?|w(?:[^\s<e][^\s<]*|e(?:[^\s<a][^\s<]*|a(?:[^\s<t][^\s<]*|t(?:[^\s<h][^\s<]*|h(?:[^\s<e][^\s<]*|e(?:[^\s<r][^\s<]*|r(?:[^\s<][^\s<]*))?)?)?)?)?)?)?)?)?)?)/
`<|channel|>analysis <|constrain|>xml<|message|>`
sequence
star
sequence
optional `\n`
sequence
`<rfl:parameter name="`
/[a-zA-Z_][a-zA-Z0-9_]*/
`">\n`
or
text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`]
json(number) excluding [`<|end|>`, `<|call|>`]
json(boolean) excluding [`<|end|>`, `<|call|>`]
json(null) excluding [`<|end|>`, `<|call|>`]
json({ ... }) excluding [`<|end|>`, `<|call|>`]
json(any[]) excluding [`<|end|>`, `<|call|>`]
`\n</rfl:parameter>`
star
sequence
`<|end|><|start|>assistant`
or
sequence
` to=get_weather<|channel|>analysis <|constrain|>xml<|message|>`
or
sequence
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
` to=get_time<|channel|>analysis <|constrain|>xml<|message|>`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="zone">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
sequence
sequence
` to=`
/(?:[^\s<g][^\s<]*|g(?:[^\s<e][^\s<]*|e(?:[^\s<t][^\s<]*|t(?:[^\s<_][^\s<]*|_(?:[^\s<tw][^\s<]*|t(?:[^\s<i][^\s<]*|i(?:[^\s<m][^\s<]*|m(?:[^\s<e][^\s<]*|e(?:[^\s<][^\s<]*))?)?)?|w(?:[^\s<e][^\s<]*|e(?:[^\s<a][^\s<]*|a(?:[^\s<t][^\s<]*|t(?:[^\s<h][^\s<]*|h(?:[^\s<e][^\s<]*|e(?:[^\s<r][^\s<]*|r(?:[^\s<][^\s<]*))?)?)?)?)?)?)?)?)?)?)/
`<|channel|>analysis <|constrain|>xml<|message|>`
sequence
star
sequence
optional `\n`
sequence
`<rfl:parameter name="`
/[a-zA-Z_][a-zA-Z0-9_]*/
`">\n`
or
text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`]
json(number) excluding [`<|end|>`, `<|call|>`]
json(boolean) excluding [`<|end|>`, `<|call|>`]
json(null) excluding [`<|end|>`, `<|call|>`]
json({ ... }) excluding [`<|end|>`, `<|call|>`]
json(any[]) excluding [`<|end|>`, `<|call|>`]
`\n</rfl:parameter>`
optional `<|call|>`
sequence
`<|channel|>final<|message|>`
text excluding [`<|end|>`, `<|return|>`]
optional `<|return|>`
"#]]
.assert_eq(&grammar(HfPosition::ResponseStart, ToolChoice::auto(), true).unwrap());
}
#[test]
fn named_choice_calls_that_tool_once_after_prompted_reasoning() {
expect_test::expect![[r#"
sequence
tag `` text `<|end|><|start|>assistant`
sequence
sequence
` to=get_weather<|channel|>analysis <|constrain|>xml<|message|>`
or
sequence
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
sequence
optional `\n`
tag `<rfl:parameter name="location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
optional
sequence
optional `\n`
tag `<rfl:parameter name="unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
optional `<|call|>`
"#]].assert_eq(
&grammar(HfPosition::Reasoning, ToolChoice::function("get_weather"), true).unwrap(),
);
}
#[test]
fn no_grammar_without_a_constraining_request_or_known_position() {
let non_strict = vec![tools().pop().unwrap()];
for tools in [&[][..], &non_strict] {
assert!(
grammar_with(
HfPosition::ResponseStart,
tools,
ToolChoice::auto(),
true,
false,
false
)
.is_none()
);
}
assert!(
grammar_with(
HfPosition::Other,
&tools(),
ToolChoice::required(),
true,
true,
false
)
.is_none()
);
}
#[test]
fn none_with_tools_only_answers() {
expect_test::expect![[r#"
sequence
tag `<|channel|>analysis<|message|>` .. `<|end|><|start|>assistant`
sequence
/[^<]/
text
sequence
`<|channel|>final<|message|>`
text excluding [`<|end|>`, `<|return|>`]
optional `<|return|>`
"#]]
.assert_eq(&grammar(HfPosition::ResponseStart, ToolChoice::none(), true).unwrap());
}
#[test]
fn always_constrain_answers_without_tools() {
expect_test::expect![[r#"
sequence
tag `` text `<|end|><|start|>assistant`
sequence
`<|channel|>final<|message|>`
text excluding [`<|end|>`, `<|return|>`]
optional `<|return|>`
"#]]
.assert_eq(
&grammar_with(
HfPosition::Reasoning,
&[],
ToolChoice::auto(),
true,
true,
false,
)
.unwrap(),
);
}
#[test]
fn always_constrain_allows_non_strict_calls_or_an_answer() {
let non_strict = vec![tools().pop().unwrap()];
expect_test::expect![[r#"
sequence
tag `<|channel|>analysis<|message|>` .. `<|end|><|start|>assistant`
sequence
/[^<]/
text
or
sequence
or
sequence
` to=get_time<|channel|>analysis <|constrain|>xml<|message|>`
sequence
optional
sequence
optional `\n`
tag `<rfl:parameter name="zone">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`] `\n</rfl:parameter>`
sequence
sequence
` to=`
/(?:[^\s<g][^\s<]*|g(?:[^\s<e][^\s<]*|e(?:[^\s<t][^\s<]*|t(?:[^\s<_][^\s<]*|_(?:[^\s<t][^\s<]*|t(?:[^\s<i][^\s<]*|i(?:[^\s<m][^\s<]*|m(?:[^\s<e][^\s<]*|e(?:[^\s<][^\s<]*))?)?)?)?)?)?)?)/
`<|channel|>analysis <|constrain|>xml<|message|>`
sequence
star
sequence
optional `\n`
sequence
`<rfl:parameter name="`
/[a-zA-Z_][a-zA-Z0-9_]*/
`">\n`
or
text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`]
json(number) excluding [`<|end|>`, `<|call|>`]
json(boolean) excluding [`<|end|>`, `<|call|>`]
json(null) excluding [`<|end|>`, `<|call|>`]
json({ ... }) excluding [`<|end|>`, `<|call|>`]
json(any[]) excluding [`<|end|>`, `<|call|>`]
`\n</rfl:parameter>`
optional `<|call|>`
sequence
`<|channel|>final<|message|>`
text excluding [`<|end|>`, `<|return|>`]
optional `<|return|>`
"#]]
.assert_eq(
&grammar_with(
HfPosition::ResponseStart,
&non_strict,
ToolChoice::auto(),
false,
true,
false,
)
.unwrap(),
);
}
#[test]
fn always_constrain_finishes_an_open_answer() {
expect_test::expect![[r#"
sequence
text excluding [`<|end|>`, `<|return|>`]
optional `<|return|>`
"#]]
.assert_eq(
&grammar_with(
HfPosition::Content,
&tools(),
ToolChoice::auto(),
true,
true,
false,
)
.unwrap(),
);
assert!(
grammar_with(
HfPosition::Content,
&tools(),
ToolChoice::auto(),
true,
false,
false
)
.is_none()
);
assert!(
grammar_with(
HfPosition::Content,
&tools(),
ToolChoice::required(),
true,
true,
false
)
.is_none()
);
}
#[test]
fn answer_holds_the_requested_format() {
let json = || Format::json_schema(json!({"type": "object"}));
let answer = constrained_grammar(HfPosition::ResponseStart, ToolChoice::none(), json(), false);
expect_test::expect![[r#"
sequence
tag `<|channel|>analysis<|message|>` .. `<|end|><|start|>assistant`
sequence
/[^<]/
text
sequence
`<|channel|>final<|message|>`
json(object)
optional `<|return|>`
"#]]
.assert_eq(&answer.unwrap());
// Under `auto`, the answer that may replace calls holds it too.
let auto =
constrained_grammar(HfPosition::ResponseStart, ToolChoice::auto(), json(), false).unwrap();
assert!(auto.ends_with(
"`<|channel|>final<|message|>`\n json(object)\n optional `<|return|>`\n"
));
// A forced call leaves no answer to constrain.
assert_eq!(
constrained_grammar(
HfPosition::ResponseStart,
ToolChoice::required(),
json(),
false
),
grammar(HfPosition::ResponseStart, ToolChoice::required(), true)
);
}
#[test]
fn request_constraint_applies_alone_in_an_open_answer() {
let json = Format::json_schema(json!({"type": "object"}));
assert!(constrained_grammar(HfPosition::Content, ToolChoice::auto(), json, true).is_none());
}
/// Parse `output` after the prompt under `tag_pattern`, returning the calls as
/// `(name, arguments)`.
fn calls(tag_pattern: &str, output: &str) -> Vec<(String, Value)> {
let mut parser = parser_with(tag_pattern, PROMPT);
let mut events = UnifiedParserOutput::default();
parser.parse_into(DecodedText::unattributed(output), &mut events).unwrap();
events.append(parser.finish().unwrap());
let mut calls: Vec<(String, String)> = Vec::new();
for event in events.events {
if let UnifiedParserEvent::ToolCall(delta) = event {
if let Some(name) = delta.name {
calls.push((name, String::new()));
}
calls.last_mut().unwrap().1.push_str(&delta.arguments);
}
}
calls
.into_iter()
.map(|(name, arguments)| (name, serde_json::from_str(&arguments).unwrap()))
.collect()
}
/// Output in the grammar's canonical spelling parses back to the values the
/// grammar admitted.
#[test]
fn parser_reads_back_the_grammar_spelling() {
let parameter = |key: &str, value: &str| {
format!("<rfl:parameter name=\"{key}\">\n{value}\n</rfl:parameter>")
};
let call = |name: &str, parameters: &[String]| {
format!(
" to={name}<|channel|>analysis <|constrain|>xml<|message|>{}",
parameters.join("\n")
)
};
let output = format!(
"<|channel|>analysis<|message|>Two tools.<|end|><|start|>assistant{}<|end|><|start|>assistant{}",
call(
"get_weather",
&[
parameter("location", "Paris"),
parameter("days", "2"),
parameter("unit", "c")
]
),
call("get_time", &[parameter("zone", "multi\nline")]),
);
for tag_pattern in [TAG_PATTERN, LENIENT_TAG_PATTERN] {
assert_eq!(
calls(tag_pattern, &output),
vec![
(
"get_weather".to_string(),
json!({"location": "Paris", "days": 2, "unit": "c"})
),
("get_time".to_string(), json!({"zone": "multi\nline"})),
]
);
}
}
#[test]
fn values_end_before_the_next_parameter_where_the_template_looks_ahead() {
let output = " to=get_weather<|channel|>analysis <|constrain|>xml<|message|>\
<rfl:parameter name=\"days\">\n2\n\
<rfl:parameter name=\"location\">\nParis\n</rfl:parameter>";
assert!(parser_with(LENIENT_TAG_PATTERN, PROMPT).lenient);
assert_eq!(
calls(LENIENT_TAG_PATTERN, output),
vec![(
"get_weather".to_string(),
json!({"days": 2, "location": "Paris"})
)]
);
assert!(!parser(PROMPT).lenient);
}
#[test]
fn reasoning_ends_at_every_closer_of_the_template() {
let closers = [
"<|end|><|start|>assistant",
"<|constrain|><|start|>assistant",
"<|start|>assistant",
];
let tools = tools();
let tokenizer = Arc::new(TestTokenizer::new());
let mut parser = BeamUnifiedParser::new(
template_with(TAG_PATTERN, json!(closers)),
&tools,
tokenizer.clone(),
);
parser.initialize(&tokenizer.encode(PROMPT, false).unwrap()).unwrap();
let grammar = parser
.build_output_grammar(&OutputGrammarContext {
tools: &tools,
tool_choice: &ToolChoice::required(),
tool_strict_level: ToolStrictLevel::Auto,
parallel_tool_calls: false,
answer: None,
})
.unwrap()
.unwrap();
assert_eq!(
outline(&grammar.format).lines().nth(1).unwrap(),
" tag `<|channel|>analysis<|message|>` .. `<|constrain|><|start|>assistant` | \
`<|end|><|start|>assistant` | `<|start|>assistant`"
);
}
#[test]
fn lenient_arguments_come_in_declared_or_sorted_order() {
expect_test::expect![[r#"
sequence
tag `` text `<|end|><|start|>assistant`
sequence
sequence
` to=get_weather<|channel|>analysis <|constrain|>xml<|message|>`
sequence
optional `\n`
`<rfl:parameter name="`
or
sequence
or
tag `location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`, `\n<rfl:parameter name="`] `\n</rfl:parameter>`
sequence
tag `location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`, `\n<rfl:parameter name="`] `\n</rfl:parameter><rfl:parameter name="` | `\n</rfl:parameter>\n<rfl:parameter name="` | `\n<rfl:parameter name="`
or
tag `days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter>`
sequence
optional tag `days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter><rfl:parameter name="` | `\n</rfl:parameter>\n<rfl:parameter name="` | `\n<rfl:parameter name="`
tag `unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
sequence
optional tag `days">\n` json(integer) excluding [`<|end|>`, `<|call|>`] `\n</rfl:parameter><rfl:parameter name="` | `\n</rfl:parameter>\n<rfl:parameter name="` | `\n<rfl:parameter name="`
or
tag `location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`, `\n<rfl:parameter name="`] `\n</rfl:parameter>`
sequence
tag `location">\n` text excluding [`<|end|>`, `<|call|>`, `</rfl:parameter>`, `\n<rfl:parameter name="`] `\n</rfl:parameter><rfl:parameter name="` | `\n</rfl:parameter>\n<rfl:parameter name="` | `\n<rfl:parameter name="`
tag `unit">\n` .. `\n</rfl:parameter>`
or
`c`
`f`
optional `<|call|>`
"#]].assert_eq(
&grammar_with(
HfPosition::Reasoning,
&tools(),
ToolChoice::function("get_weather"),
true,
false,
true,
)
.unwrap(),
);
}
#[test]
fn other_names_match_every_name_but_the_given_ones() {
let regex = regex_automata::meta::Regex::new(&format!(
"^(?:{})$",
other_names(&["get", "get_time", "a.b"])
))
.unwrap();
for name in [
"g",
"ge",
"gets",
"get_",
"get_t",
"get_times",
"x",
"a",
"a.",
"axb",
"a.bc",
] {
assert!(regex.is_match(name), "{name}");
}
for name in ["get", "get_time", "a.b", "", "get time", "get<"] {
assert!(!regex.is_match(name), "{name}");
}
}
+77 -2
View File
@@ -6,8 +6,12 @@
//! Port of `content_parsers.py`. Each parser takes a chunk of captured text and
//! parses it into a single key in the output message dictionary.
use std::borrow::Cow;
use regex_automata::meta::Regex;
use regex_automata::util::iter::Searcher;
use regex_automata::util::syntax;
use regex_automata::{Input, Match};
use serde::Deserialize;
use serde_json::{Map, Value};
use thiserror_ext::AsReport as _;
@@ -22,6 +26,10 @@ use crate::unified::{self, parsing_failed};
const LAX_OPEN: char = '\u{1}';
const LAX_CLOSE: char = '\u{2}';
/// Name of the group that stands for the lookahead ending an `xml-inline` tag
/// pattern.
const AHEAD_GROUP: &str = "lookahead";
/// A configured content parser (`content` plus `content_args` of one field).
#[derive(Debug, Clone)]
pub(super) enum ContentParser {
@@ -66,6 +74,9 @@ pub(super) struct XmlInlineArgs {
key_group: usize,
/// Index of the `value` group, if the pattern declares one.
value_group: Option<usize>,
/// Index of the group standing for the lookahead that ends the pattern,
/// and the lookahead's own pattern, if any.
lookahead: Option<(usize, String)>,
value_parser: Option<Box<ContentParser>>,
/// Collect duplicate keys into a list.
merge_duplicates: bool,
@@ -143,7 +154,8 @@ impl ContentParser {
}
ContentKind::XmlInline => {
let spec: XmlInlineSpec = deserialize(&args, &context)?;
let tag_pattern = compile_regex(scope, &spec.tag_pattern)?;
let (pattern, lookahead) = final_lookahead(&spec.tag_pattern);
let tag_pattern = compile_regex(scope, &pattern)?;
let group_info = tag_pattern.group_info();
let Some(key_group) = group_info.to_index(Default::default(), "key") else {
return Err(invalid!(
@@ -152,10 +164,14 @@ impl ContentParser {
));
};
let value_group = group_info.to_index(Default::default(), "value");
let lookahead = group_info
.to_index(Default::default(), AHEAD_GROUP)
.zip(lookahead.map(str::to_string));
Self::XmlInline(XmlInlineArgs {
tag_pattern,
key_group,
value_group,
lookahead,
value_parser: ValueParserSpec::compile(scope, spec.value_parser)?,
merge_duplicates: spec.merge_duplicates,
})
@@ -193,6 +209,32 @@ impl ContentParser {
Self::KvLines(args) => args.parse(text),
}
}
/// The pattern of the lookahead that ends an `xml-inline` tag pattern, if
/// any.
pub fn lookahead(&self) -> Option<&str> {
match self {
Self::XmlInline(args) => args.lookahead.as_ref().map(|(_, ahead)| ahead.as_str()),
_ => None,
}
}
}
/// `pattern` with a lookahead that ends it matched as the group
/// [`AHEAD_GROUP`] instead, as the Rust regex engine has no lookahead, and
/// the lookahead's own pattern. The engine rejects any other lookahead.
fn final_lookahead(pattern: &str) -> (Cow<'_, str>, Option<&str>) {
if let Some((head, rest)) = pattern.split_once("(?=") {
// Only closing parentheses may follow a lookahead that ends the pattern.
let ahead = rest.trim_end_matches(')');
if ahead.len() < rest.len() && !ahead.contains(['(', ')']) {
return (
format!("{head}(?P<{AHEAD_GROUP}>{rest}").into(),
Some(ahead),
);
}
}
(pattern.into(), None)
}
impl ValueParserSpec {
@@ -258,7 +300,21 @@ impl XmlInlineArgs {
/// Parse shallow XML-ish tags into a dict.
fn parse(&self, text: &str) -> unified::Result<Value> {
let mut out = Map::new();
for captures in self.tag_pattern.captures_iter(text) {
let mut captures = self.tag_pattern.create_captures();
let mut tags = Searcher::new(Input::new(text));
while tags
.advance(|input| {
self.tag_pattern.search_captures(input, &mut captures);
// The lookahead's text is not part of the tag.
let ahead =
self.lookahead.as_ref().and_then(|(group, _)| captures.get_group(*group));
Ok(captures.get_match().map(|tag| match ahead {
Some(ahead) => Match::new(tag.pattern(), tag.start()..ahead.start),
None => tag,
}))
})
.is_some()
{
let key =
captures.get_group(self.key_group).map(|span| &text[span.range()]).ok_or_else(
|| parsing_failed!("xml-inline: named group 'key' did not participate"),
@@ -490,6 +546,25 @@ mod tests {
);
}
#[test]
fn xml_inline_tags_may_end_where_the_pattern_looks_ahead() {
let xml = parser(
"xml-inline",
json!({
"tag_pattern": r#"<arg name="(?P<key>\w+)">(?P<value>.*?)\n(?:</arg>|(?=<arg name="))"#,
}),
);
let value = xml
.parse("<arg name=\"a\">1\n<arg name=\"b\">x <arg name=\"c\"> y\n</arg>")
.unwrap();
assert_eq!(value, json!({"a": "1", "b": "x <arg name=\"c\"> y"}));
assert_eq!(xml.lookahead(), Some("<arg name=\""));
// Any other lookahead is unsupported.
let args = json!({"tag_pattern": r"(?P<key>\w+)(?=:)(?P<value>.*)"});
assert!(ContentParser::new("test", kind("xml-inline"), args.as_object().unwrap()).is_err());
}
#[test]
fn kv_lines_split_and_strip() {
let kv = parser("kv-lines", json!({"value_parser": {"name": "int"}}));
+1 -1
View File
@@ -25,7 +25,7 @@ mod transform;
#[cfg(test)]
mod tests;
pub use parser::HfUnifiedParser;
pub use parser::{HfPosition, HfUnifiedParser};
pub use template::ResponseTemplate;
use thiserror::Error;
use thiserror_ext::Macro;
+44
View File
@@ -59,6 +59,20 @@ pub struct HfUnifiedParser {
emitted_tool_count: usize,
}
/// Where the parser is in the response; see [`HfUnifiedParser::position`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HfPosition {
/// Nothing parsed since the start anchor, or since the start of the stream
/// when there is none.
ResponseStart,
/// Inside an open reasoning field.
Reasoning,
/// Inside an open content field.
Content,
/// Anywhere else.
Other,
}
/// Parser mode: the region currently receiving text.
enum HfMode {
/// No implicit field is declared: text outside regions is discarded.
@@ -156,6 +170,36 @@ impl HfUnifiedParser {
parser
}
/// Where the parser is in the response. Between
/// [`UnifiedParser::initialize`] and the first generated text, this is where
/// the prompt leaves the response.
///
/// Text outside every region, which a template without an implicit field
/// discards, leaves no state behind and so does not move the parser from
/// [`HfPosition::ResponseStart`].
pub fn position(&self) -> HfPosition {
if !self.buffer.text.is_empty() {
return HfPosition::Other;
}
match &self.mode {
HfMode::Implicit(occurrence) | HfMode::Explicit(occurrence) if occurrence.opened => {
match &self.template.regions[occurrence.region].kind {
RegionKind::Text(TextRegion {
role: TextRole::Reasoning,
..
}) => HfPosition::Reasoning,
RegionKind::Text(TextRegion {
role: TextRole::Content,
..
}) => HfPosition::Content,
_ => HfPosition::Other,
}
}
_ if self.closed.contains(&true) || self.emitted_tool_count > 0 => HfPosition::Other,
_ => HfPosition::ResponseStart,
}
}
/// Clear all per-stream state.
fn reset_state(&mut self) {
self.buffer.clear();
@@ -191,6 +191,31 @@ impl ResponseTemplate {
})
}
/// The pattern of the lookahead that ends the `xml-inline` tag pattern of
/// tool-call arguments, such as for the opener of the next argument.
pub fn argument_lookahead(&self) -> Option<&str> {
self.regions.iter().find_map(|region| match &region.kind {
RegionKind::ToolCalls(calls) => calls.content.lookahead(),
_ => None,
})
}
/// The literal closers of the reasoning field, longest first, or none if it
/// has no literal close.
pub fn reasoning_closers(&self) -> &[String] {
self.regions
.iter()
.find_map(|region| match (&region.kind, &region.close) {
(RegionKind::Text(text), Some(Boundary::Literals(closers)))
if text.role == TextRole::Reasoning =>
{
Some(closers.as_slice())
}
_ => None,
})
.unwrap_or_default()
}
/// Return the start and end offsets of the last start-anchor match in
/// `prompt`.
pub(super) fn last_start_anchor(&self, prompt: &str) -> Option<(usize, usize)> {
+3 -1
View File
@@ -3,6 +3,7 @@
//! Unified parser interface for reasoning and tool-call deltas.
mod beam;
mod combined;
mod gemma4;
mod hf;
@@ -10,9 +11,10 @@ mod hy;
mod inkling;
mod kimi_k3;
pub use beam::BeamUnifiedParser;
pub use combined::CombinedParser;
pub use gemma4::Gemma4UnifiedParser;
pub use hf::{HfTemplateError, HfUnifiedParser, ResponseTemplate};
pub use hf::{HfPosition, HfTemplateError, HfUnifiedParser, ResponseTemplate};
pub use hy::{HyV3UnifiedParser, HyV4UnifiedParser};
pub use inkling::InklingUnifiedParser;
pub use kimi_k3::KimiK3UnifiedParser;
+747
View File
@@ -0,0 +1,747 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from copy import deepcopy
from types import SimpleNamespace
from typing import Any
import pytest
from openai.types.responses import FunctionTool, ToolChoiceFunction
from xgrammar import Grammar
from xgrammar.testing import _is_grammar_accept_string
from vllm.config.reasoning import ReasoningConfig
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
ChatCompletionToolsParam,
)
from vllm.entrypoints.openai.responses.protocol import (
ResponsesRequest,
ResponsesResponse,
)
from vllm.exceptions import VLLMValidationError
from vllm.parser.beam import BeamParser, BeamReasoningParser, BeamToolParser
from vllm.parser.parser_manager import ParserManager
from vllm.parser.response_template import ResponseTemplateParser
from vllm.reasoning import ReasoningParserManager
from vllm.tool_parsers import ToolParserManager
# The `response_template` of the Beam checkpoints.
BEAM_RESPONSE_TEMPLATE: dict[str, Any] = {
"defaults": {"role": "assistant"},
"start_anchor_pattern": r"<\|start\|>assistant",
"fields": {
"thinking": {
"open": "<|channel|>analysis<|message|>",
"close": "<|end|><|start|>assistant",
"content": "text",
},
"content": {
"open": "<|channel|>final<|message|>",
"close": ["<|return|>", "<|end|>", "<|call|>"],
"content": "text",
},
"tool_calls": {
"open_pattern": (
r" to=(?P<name>[^\s<]+)"
r"<\|channel\|>analysis <\|constrain\|>xml<\|message\|>"
),
"close": ["<|end|><|start|>assistant", "<|call|>"],
"repeats": True,
"content": "xml-inline",
"content_args": {
"tag_pattern": (
r'<rfl:parameter name="(?P<key>[^"]+)">'
r'\n(?P<value>.*?)\n(?:</rfl:parameter>|(?=<rfl:parameter name="))'
),
"value_parser": {"name": "text", "args": {"strip": False}},
},
"transform": {
"type": "function",
"function": {"name": "{name}", "arguments": "{content}"},
},
},
},
}
VOCAB = {
"<|start|>": 200,
"<|channel|>": 201,
"<|message|>": 202,
"<|end|>": 203,
"<|call|>": 204,
"<|return|>": 205,
"<|constrain|>": 206,
}
TEXT_TOKENS = {100: "assistant", 101: "analysis", 102: "final", 103: "plan"}
PROMPT_TOKEN_IDS = [200, 100]
THOUGHT = "<|channel|>analysis<|message|>plan<|end|><|start|>assistant"
FINAL = "<|channel|>final<|message|>"
NEXT_MESSAGE = "<|end|><|start|>assistant"
def _tool(name, properties, *, strict=None):
return ChatCompletionToolsParam(
type="function",
function={
"name": name,
"parameters": {"type": "object", "properties": properties},
"strict": strict,
},
)
TOOLS = [
_tool("get_weather", {"city": {"type": "string"}}),
_tool("set_count", {"count": {"type": "integer"}}),
]
STRICT_TOOLS = [
_tool("get_weather", {"city": {"type": "string"}}),
_tool("set_count", {"count": {"type": "integer"}}, strict=True),
]
class BeamTokenizer:
def __init__(self, response_template=BEAM_RESPONSE_TEMPLATE):
self.response_template = response_template
self.init_kwargs = {}
self.name_or_path = ""
self.all_special_tokens = list(VOCAB)
self.all_special_ids = list(VOCAB.values())
def decode(self, token_ids, skip_special_tokens=False, **_kwargs):
inverse_vocab = {token_id: token for token, token_id in VOCAB.items()}
return "".join(
TEXT_TOKENS.get(token_id, inverse_vocab.get(token_id, ""))
for token_id in token_ids
)
def get_vocab(self):
return VOCAB
def encode(self, text, add_special_tokens=False):
del add_special_tokens
return list(text.encode())
def request(**overrides):
values = dict(
tool_choice="auto",
include_reasoning=True,
tools=TOOLS,
parallel_tool_calls=True,
)
values.update(overrides)
return SimpleNamespace(**values)
def parameter(name, value):
return f'<rfl:parameter name="{name}">\n{value}\n</rfl:parameter>'
def call(name, body, *, terminator="<|call|>"):
return (
f" to={name}<|channel|>analysis <|constrain|>xml<|message|>{body}{terminator}"
)
def make_parser(prompt_token_ids=PROMPT_TOKEN_IDS, tools=TOOLS, **kwargs):
parser_cls = ParserManager.get_parser(
tool_parser_name="beam",
reasoning_parser_name="beam",
enable_auto_tools=True,
tokenizer=BeamTokenizer(),
)
assert parser_cls is not None
parser = parser_cls(BeamTokenizer(), tools, **kwargs)
parser.set_prompt_token_ids(prompt_token_ids)
return parser
def parse(text, prompt_token_ids=PROMPT_TOKEN_IDS):
reasoning, content, calls = make_parser(prompt_token_ids).parse(
text, request(), enable_auto_tools=True
)
return reasoning, content, normalize_calls(calls)
def normalize_calls(calls):
return [(call.name, json.loads(call.arguments)) for call in calls or []]
def test_beam_parsers_are_registered():
assert ToolParserManager.get_tool_parser("beam") is BeamToolParser
assert ReasoningParserManager.get_reasoning_parser("beam") is BeamReasoningParser
def test_parser_manager_selects_beam_parser():
parser_cls = ParserManager.get_parser(
tool_parser_name="beam",
reasoning_parser_name="beam",
enable_auto_tools=True,
tokenizer=BeamTokenizer(),
)
assert parser_cls is not None
assert issubclass(parser_cls, BeamParser)
assert parser_cls.reasoning_parser_cls is BeamReasoningParser
assert parser_cls.tool_parser_cls is BeamToolParser
assert parser_cls._enable_auto_tools
@pytest.mark.parametrize("other", ["hermes", "hf"])
def test_beam_parser_rejects_other_parsers(other):
with pytest.raises(TypeError, match="cannot be combined"):
ParserManager.get_parser(
tool_parser_name=other,
reasoning_parser_name="beam",
enable_auto_tools=True,
tokenizer=BeamTokenizer(),
)
def test_beam_parser_requires_checkpoint_metadata_at_startup():
with pytest.raises(
TypeError, match="--reasoning-parser beam requires `response_template`"
):
ParserManager.get_parser(
reasoning_parser_name="beam",
tokenizer=BeamTokenizer(response_template=None),
)
template = deepcopy(BEAM_RESPONSE_TEMPLATE)
del template["fields"]["tool_calls"]
with pytest.raises(TypeError, match="--tool-call-parser beam requires"):
ParserManager.get_parser(
tool_parser_name="beam",
enable_auto_tools=True,
tokenizer=BeamTokenizer(template),
)
def test_reasoning_and_final_answer():
assert parse(THOUGHT + FINAL + "Sunny.<|return|>") == ("plan", "Sunny.", [])
def test_prefilled_reasoning_continues_from_prompt():
prompt = PROMPT_TOKEN_IDS + [201, 101, 202, 103]
text = " more.<|end|><|start|>assistant" + FINAL + "Done.<|return|>"
assert parse(text, prompt) == (" more.", "Done.", [])
def test_parallel_calls_are_coerced_to_their_schemas():
text = (
THOUGHT
+ call(
"get_weather",
parameter("city", "Paris"),
terminator="<|end|><|start|>assistant",
)
+ call("set_count", parameter("count", "7"))
)
assert parse(text) == (
"plan",
None,
[("get_weather", {"city": "Paris"}), ("set_count", {"count": 7})],
)
def test_call_stop_token_removed_from_the_text_still_ends_the_call():
text = THOUGHT + call("get_weather", parameter("city", "Paris"), terminator="")
assert parse(text) == ("plan", None, [("get_weather", {"city": "Paris"})])
@pytest.mark.parametrize("end", ["<|end|>", "<|end|><|start|>"])
def test_output_cut_before_the_next_message_start_ends_its_region(end):
"""Output that stops after `<|end|>`, such as at `max_tokens`, has ended
its reasoning or call and lacks only the next message's start."""
weather = call("get_weather", parameter("city", "Paris"), terminator=end)
assert parse("<|channel|>analysis<|message|>plan" + end) == ("plan", None, [])
assert parse(THOUGHT + weather) == (
"plan",
None,
[("get_weather", {"city": "Paris"})],
)
THOUGHT_IDS = [201, 101, 202, 103, 203, 200, 100]
FINAL_IDS = [201, 102, 202]
@pytest.mark.parametrize(
("prompt_token_ids", "token_ids", "count"),
[
(PROMPT_TOKEN_IDS, [*THOUGHT_IDS, *FINAL_IDS, 205], 1),
(PROMPT_TOKEN_IDS + [201, 101, 202], [103, 103, 203, 200, 100, *FINAL_IDS], 2),
(PROMPT_TOKEN_IDS, [201, 101, 202, 103, 103, 103], 3),
],
ids=["reasoning_then_answer", "prefilled_reasoning", "cut_off"],
)
def test_reasoning_tokens_are_counted(prompt_token_ids, token_ids, count):
parser = make_parser(prompt_token_ids)
assert parser.count_reasoning_tokens(token_ids) == count
def test_reasoning_tokens_are_counted_as_the_generation_grows():
token_ids = [*THOUGHT_IDS, *FINAL_IDS, 205]
parser = make_parser()
assert [
parser.count_reasoning_tokens(token_ids[:end])
for end in range(1, len(token_ids) + 1)
] == [
make_parser().count_reasoning_tokens(token_ids[:end])
for end in range(1, len(token_ids) + 1)
]
assert parser.count_reasoning_tokens(FINAL_IDS) == 0
def test_call_stop_token_ends_the_answer():
assert parse(FINAL + "done<|call|>") == (None, "done", [])
@pytest.mark.parametrize("chunk_size", [1, 7, 10_000])
def test_streaming_matches_non_streaming(chunk_size):
text = (
THOUGHT
+ call(
"get_weather",
parameter("city", "Paris"),
terminator="<|end|><|start|>assistant",
)
+ call("set_count", parameter("count", "7"))
)
parser = make_parser()
reasoning, calls = [], []
for start in range(0, len(text), chunk_size):
end = min(start + chunk_size, len(text))
delta = parser.parse_delta(
text[start:end], [], request(), finished=end == len(text)
)
if delta is not None:
reasoning.append(delta.reasoning or "")
calls.extend(delta.tool_calls)
assert "".join(reasoning) == "plan"
assert [
(call.function.name, json.loads(call.function.arguments)) for call in calls
] == parse(text)[2]
WEATHER_CALL = call("get_weather", parameter("city", "Paris"))
WEATHER = [("get_weather", {"city": "Paris"})]
def chat_request(tools=STRICT_TOOLS, **body):
return ChatCompletionRequest(
model="beam",
messages=[{"role": "user", "content": "Weather?"}],
tools=tools and [tool.model_dump() for tool in tools],
**body,
)
def named(name):
return {"type": "function", "function": {"name": name}}
def adjust(req, prompt_token_ids=PROMPT_TOKEN_IDS, **kwargs):
make_parser(prompt_token_ids, req.tools, **kwargs).adjust_request(req)
return req
def accepts(req, text):
grammar = Grammar.from_structural_tag(
req.extract_structured_outputs().structural_tag
)
return _is_grammar_accept_string(grammar, text)
@pytest.mark.parametrize(
("tool_choice", "text", "calls"),
[
("required", THOUGHT + WEATHER_CALL, WEATHER),
("required", WEATHER_CALL, None),
(
"required",
THOUGHT
+ call("get_weather", parameter("city", "Paris"), terminator=NEXT_MESSAGE)
+ call("set_count", parameter("count", "7")),
[*WEATHER, ("set_count", {"count": 7})],
),
("required", THOUGHT + call("set_count", parameter("count", "x")), None),
(
"required",
THOUGHT + call("get_weather", parameter("city", "a<|call|>b")),
None,
),
("required", THOUGHT + FINAL + "Paris<|return|>", None),
(named("set_count"), THOUGHT + WEATHER_CALL, None),
(named("get_weather"), THOUGHT + WEATHER_CALL, WEATHER),
(
named("get_weather"),
THOUGHT
+ call("get_weather", parameter("city", "Paris"), terminator=NEXT_MESSAGE)
+ WEATHER_CALL,
None,
),
],
)
def test_forced_tool_calls_follow_the_template_grammar(tool_choice, text, calls):
"""From the message start, the grammar opens with the reasoning, and
accepts only calls the parser reads back."""
req = adjust(chat_request(tool_choice=tool_choice))
assert req._grammar_from_parser
assert accepts(req, text) is (calls is not None)
if calls is not None:
assert parse(text)[2] == calls
def test_reasoning_may_be_skipped_when_thinking_is_disabled():
req = adjust(
chat_request(tool_choice="required"),
chat_template_kwargs={"enable_thinking": False},
)
assert accepts(req, WEATHER_CALL)
assert accepts(req, THOUGHT + WEATHER_CALL)
def test_parallel_tool_calls_false_allows_a_single_call():
req = adjust(chat_request(tool_choice="required", parallel_tool_calls=False))
calls = call("get_weather", parameter("city", "Paris"), terminator=NEXT_MESSAGE)
assert accepts(req, THOUGHT + WEATHER_CALL)
assert not accepts(req, THOUGHT + calls + WEATHER_CALL)
@pytest.mark.parametrize("strict", [False, True])
@pytest.mark.parametrize(
("separator", "accepted"), [("", True), ("\n", True), ("\n\n", False), (" ", False)]
)
def test_parameters_are_separated_by_at_most_a_newline(strict, separator, accepted):
"""A model that skips a parameter's opener must not write whitespace until
`max_tokens`."""
write = _tool(
"write",
{"path": {"type": "string"}, "content": {"type": "string"}},
strict=strict,
)
req = adjust(chat_request([write], tool_choice="required"))
body = parameter("path", "a.py") + separator + parameter("content", "x")
assert accepts(req, THOUGHT + call("write", body)) is accepted
assert accepts(req, THOUGHT + call("write", separator + body)) is accepted
def test_forced_calls_in_prefilled_reasoning_start_after_it():
req = adjust(
chat_request(tool_choice="required"), PROMPT_TOKEN_IDS + [201, 101, 202, 103]
)
assert not req._grammar_from_parser
assert accepts(req, WEATHER_CALL)
def test_forced_calls_cannot_follow_a_prefilled_answer():
with pytest.raises(VLLMValidationError, match="required tool choice"):
adjust(chat_request(tool_choice="required"), PROMPT_TOKEN_IDS + [201, 102, 202])
def test_prefilled_answer_can_only_be_finished():
req = adjust(chat_request(TOOLS), PROMPT_TOKEN_IDS + [201, 102, 202])
assert accepts(req, "Sunny.<|return|>")
assert not accepts(req, "Sunny." + NEXT_MESSAGE + WEATHER_CALL)
def test_forced_call_with_unencodable_tool_name_is_rejected():
tools = [_tool("get weather", {"city": {"type": "string"}})]
with pytest.raises(VLLMValidationError, match="tool name"):
adjust(chat_request(tools, tool_choice="required"))
def test_string_formats_and_patterns_are_held_in_raw_text():
"""Like XGrammar, a strict string keeps its format or pattern, unless a
match could end the value early."""
contact = _tool(
"set_contact",
{
"email": {"type": "string", "format": "email"},
"code": {"type": "string", "pattern": "^[A-Z]{3}$"},
"n": {"type": "integer"},
},
strict=True,
)
contact.function.parameters["required"] = ["email", "code", "n"]
req = adjust(chat_request([contact], tool_choice="required"))
def contact_call(email, code, *rest):
body = parameter("email", email) + parameter("code", code) + "".join(rest)
return THOUGHT + call("set_contact", body)
text = contact_call("a.b@x.co", "ABC", parameter("n", "1"))
calls = make_parser(tools=[contact]).parse(text, req, enable_auto_tools=True)[2]
assert accepts(req, text)
assert normalize_calls(calls) == [
("set_contact", {"email": "a.b@x.co", "code": "ABC", "n": 1})
]
assert not accepts(req, contact_call("ab", "ABC", parameter("n", "1")))
assert not accepts(req, contact_call("a.b@x.co", "ABCD", parameter("n", "1")))
assert not accepts(req, contact_call("a.b@x.co", "ABC"))
# Line breaks could end the value.
dotted = _tool(
"set_code", {"code": {"type": "string", "pattern": "^a.b$"}}, strict=True
)
with pytest.raises(VLLMValidationError, match="required tool choice"):
adjust(chat_request([dotted], tool_choice="required"))
@pytest.mark.parametrize(
("text", "accepted"),
[
(THOUGHT + WEATHER_CALL, True),
(THOUGHT + FINAL + "Sunny.<|return|>", True),
(THOUGHT + FINAL + "Sunny." + NEXT_MESSAGE + WEATHER_CALL, False),
(THOUGHT + call("set_count", parameter("count", "x")), False),
],
)
def test_strict_tools_constrain_automatic_tool_choice(text, accepted):
assert accepts(adjust(chat_request()), text) is accepted
@pytest.mark.parametrize(
("text", "accepted"),
[
(THOUGHT + FINAL + "Sunny.<|return|>", True),
(FINAL + "Sunny.<|return|>", False),
(WEATHER_CALL, False),
(THOUGHT + WEATHER_CALL, False),
(FINAL + "Sunny." + NEXT_MESSAGE + WEATHER_CALL, False),
],
)
def test_tool_choice_none_allows_only_an_answer(text, accepted):
req = adjust(chat_request(TOOLS, tool_choice="none"))
assert req._grammar_from_parser
assert accepts(req, text) is accepted
@pytest.mark.parametrize(("tools", "tool_choice"), [(TOOLS, "auto"), (None, None)])
def test_always_constrained_output_follows_the_template_framing(tools, tool_choice):
req = adjust(chat_request(tools, tool_choice=tool_choice))
count_call = THOUGHT + call("set_count", parameter("count", "3"))
assert accepts(req, THOUGHT + FINAL + "Sunny.<|return|>")
assert not accepts(req, FINAL + "Sunny.<|return|>")
assert not accepts(req, THOUGHT.replace("plan", "") + FINAL + "Sunny.<|return|>")
assert not accepts(req, THOUGHT + "Sunny.")
assert accepts(req, count_call) is (tool_choice == "auto")
@pytest.mark.parametrize("response_format", [None, {"type": "json_object"}])
@pytest.mark.parametrize("strict", [None, True])
@pytest.mark.parametrize("tool_choice", ["auto", "required"])
def test_automatic_choice_lets_calls_name_undeclared_tools(
strict, tool_choice, response_format
):
"""Unless a forced choice limits calls to the declared tools. The parser
passes such calls on, so the harness can tell the model the tool does not
exist."""
bash = _tool("bash", {"command": {"type": "string"}}, strict=strict)
req = adjust(
chat_request([bash], tool_choice=tool_choice, response_format=response_format)
)
undeclared = THOUGHT + call("read_file", parameter("path", "a.py"))
parser = make_parser(tools=[bash])
assert accepts(req, THOUGHT + call("bash", parameter("command", "ls")))
assert accepts(req, undeclared) is (tool_choice == "auto")
calls = parser.parse(undeclared, req, enable_auto_tools=True)[2]
assert normalize_calls(calls) == [("read_file", {"path": "a.py"})]
@pytest.mark.parametrize(
("name", "body", "accepted"),
[
("set_count", parameter("count", "1"), True),
("set_count", parameter("count", "x"), False),
("get_weather", parameter("town", "Paris"), False),
# The grammar cannot enforce `minProperties`.
("find", parameter("x", "1") + parameter("x", "y"), True),
("search_files", parameter("x", "1") + parameter("x", "y"), True),
("set_coun", parameter("x", "1") + parameter("x", "y"), True),
("set_counts", parameter("x", "1") + parameter("x", "y"), True),
],
)
def test_automatic_calls_enforce_declared_arguments_beside_any_other_name(
name, body, accepted
):
find = _tool("find", {"q": {"type": "string"}})
find.function.parameters["minProperties"] = 1
req = adjust(chat_request([*TOOLS, find]))
assert accepts(req, THOUGHT + call(name, body)) is accepted
def test_value_ends_before_an_opener_on_the_next_line():
write = _tool("write", {"path": {"type": "string"}, "content": {"type": "string"}})
req = adjust(chat_request([write], tool_choice="required"))
body = '<rfl:parameter name="content">\nprint(1)\n\n' + parameter("path", "a.py")
text = THOUGHT + call("write", body)
assert accepts(req, text)
calls = make_parser(tools=[write]).parse(text, req, enable_auto_tools=True)[2]
assert normalize_calls(calls) == [
("write", {"content": "print(1)\n", "path": "a.py"})
]
def test_always_constrained_output_can_be_disabled(monkeypatch):
monkeypatch.setenv("VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT", "0")
assert adjust(chat_request(TOOLS)).structured_outputs is None
def test_strict_tools_the_template_cannot_hold_leave_automatic_choice_unconstrained():
dotted = _tool(
"set_code", {"code": {"type": "string", "pattern": "^a.b$"}}, strict=True
)
assert adjust(chat_request([dotted])).structured_outputs is None
def test_generic_parser_does_not_derive_the_template_grammar():
req = chat_request(TOOLS, tool_choice="required")
parser = ResponseTemplateParser(BeamTokenizer(), req.tools, enable_auto_tools=True)
with pytest.raises(VLLMValidationError, match="required tool choice"):
parser.adjust_request(req)
@pytest.mark.parametrize("tools", [None, TOOLS])
def test_structured_output_is_framed_as_the_answer(tools):
schema = {"type": "object", "properties": {"a": {"type": "integer"}}}
req = adjust(
chat_request(
tools,
response_format={
"type": "json_schema",
"json_schema": {"name": "answer", "schema": schema},
},
)
)
assert req.response_format is None
assert accepts(req, THOUGHT + FINAL + '{"a": 1}<|return|>')
assert not accepts(req, THOUGHT + FINAL + "Sunny.<|return|>")
assert accepts(req, THOUGHT + WEATHER_CALL) is (tools is not None)
def test_responses_named_tool_choice_follows_the_template_grammar():
req = adjust(
ResponsesRequest(
input="Weather?",
tools=[
FunctionTool(
type="function",
name="get_weather",
parameters={
"type": "object",
"properties": {"city": {"type": "string"}},
},
strict=True,
)
],
tool_choice=ToolChoiceFunction(type="function", name="get_weather"),
text={"format": {"type": "json_object"}},
)
)
assert req._grammar_from_parser
assert req.text.format.type == "json_object"
assert accepts(req, THOUGHT + WEATHER_CALL)
assert not accepts(req, FINAL + "{}<|return|>")
@pytest.mark.parametrize("status", ["in_progress", "completed"])
@pytest.mark.parametrize("inside_reasoning", [False, True])
def test_responses_preserves_text_config_with_template_grammar(
status, inside_reasoning
):
text = {
"format": {
"type": "json_schema",
"name": "answer",
"strict": True,
"schema": {
"type": "object",
"properties": {"a": {"type": "integer"}},
"required": ["a"],
"additionalProperties": False,
},
},
"verbosity": "low",
}
req = ResponsesRequest(input="Answer", text=text)
expected_text = req.text.model_dump(by_alias=True)
prompt = PROMPT_TOKEN_IDS + ([201, 101, 202] if inside_reasoning else [])
adjust(req, prompt_token_ids=prompt)
params = req.to_sampling_params(default_max_tokens=128)
grammar = Grammar.from_structural_tag(params.structured_outputs.structural_tag)
prefix = "" if inside_reasoning else THOUGHT
assert _is_grammar_accept_string(grammar, prefix + FINAL + '{"a": 1}<|return|>')
assert not _is_grammar_accept_string(grammar, prefix + FINAL + "Sunny.<|return|>")
response = ResponsesResponse.from_request(req, params, "beam", 0, [], status)
assert response.model_dump(by_alias=True)["text"] == expected_text
@pytest.mark.parametrize("reverse_alternatives", [False, True])
def test_reasoning_budget_initializes_from_checkpoint_pattern(
monkeypatch, reverse_alternatives
):
template = deepcopy(BEAM_RESPONSE_TEMPLATE)
alternatives = [
r"^<\|channel\|>analysis<\|message\|>",
r"<\|start\|>assistant<\|channel\|>analysis<\|message\|>",
]
if reverse_alternatives:
alternatives.reverse()
template["fields"]["thinking"] = {
"open_pattern": "(?:" + "|".join(alternatives) + ")",
"close": "<|end|>",
"content": "text",
}
tokenizer = BeamTokenizer(template)
monkeypatch.setattr(
"vllm.config.reasoning.cached_tokenizer_from_config", lambda **_: tokenizer
)
config = ReasoningConfig(reasoning_parser="beam")
config.initialize_token_ids(SimpleNamespace())
assert config.enabled
# The marker must work both after the prompt's assistant header and in
# subsequent complete assistant messages, regardless of regex branch order.
assert config.reasoning_start_token_ids == tokenizer.encode(
"<|channel|>analysis<|message|>"
)
assert config.reasoning_end_token_ids == tokenizer.encode("<|end|>")
assert config.natural_reasoning_end_token_ids == tokenizer.encode("<|end|>")
@pytest.mark.parametrize("pattern", [r"analysis.*", r"(?:analysis|thinking)"])
def test_reasoning_budget_does_not_guess_a_marker_for_other_patterns(pattern):
template = deepcopy(BEAM_RESPONSE_TEMPLATE)
thinking = template["fields"]["thinking"]
del thinking["open"]
thinking["open_pattern"] = pattern
assert BeamReasoningParser(BeamTokenizer(template)).reasoning_start_str is None
@@ -0,0 +1,492 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from copy import deepcopy
from typing import Any
import pytest
from xgrammar import Grammar
from xgrammar.testing import _is_grammar_accept_string
from vllm.parser.chat_parsing.response_templates import load_response_template
from vllm.parser.response_template_grammar import (
answer_rest_structural_tag,
tool_call_structural_tag,
)
TEMPLATE: dict[str, Any] = {
"start_anchor": "<assistant>",
"fields": {
"thinking": {"open": "<think>", "close": "</think>", "content": "text"},
"content": {"content": "text"},
"tool_calls": {
"open_pattern": r'<call name="(?P<name>[\w.-]+)">',
"close": "</call>",
"repeats": True,
"content": "xml-inline",
"content_args": {
"tag_pattern": r'<arg name="(?P<key>[^"]+)">(?P<value>.*?)</arg>'
},
"transform": {
"type": "function",
"function": {"name": "{name}", "arguments": "{content}"},
},
},
},
}
ANSWER_TEMPLATE = deepcopy(TEMPLATE)
ANSWER_TEMPLATE["fields"]["content"] = {
"open": "<answer>",
"close": "</answer>",
"content": "text",
}
def tool(parameters, name="f", strict=True) -> dict[str, Any]:
return {"name": name, "parameters": parameters, "strict": strict}
WEATHER = tool(
{
"type": "object",
"properties": {"city": {"type": "string"}, "days": {"type": "integer"}},
"required": ["city"],
},
name="get.weather",
)
PING = {"name": "ping"}
CALL = '<call name="get.weather"><arg name="city">Paris</arg></call>'
def build(
template=TEMPLATE,
functions=(WEATHER, PING),
*,
parallel=True,
thinking=True,
answer=False,
answer_format=None,
sorted_arguments=False,
):
fields = load_response_template(template).fields
return tool_call_structural_tag(
fields["tool_calls"],
list(functions),
parallel_tool_calls=parallel,
thinking=fields["thinking"] if thinking else None,
answer=fields["content"] if answer else None,
answer_format=answer_format,
sorted_arguments=sorted_arguments,
)
def accepts(tag, text):
return _is_grammar_accept_string(Grammar.from_structural_tag(json.dumps(tag)), text)
def call(*arguments):
body = "".join(f'<arg name="{key}">{value}</arg>' for key, value in arguments)
return f'<call name="f">{body}</call>'
@pytest.mark.parametrize(
("text", "accepted"),
[
("<think>plan</think>" + CALL, True),
(CALL, True),
(CALL.removesuffix("</call>"), True),
(
'<call name="get.weather"><arg name="city">Paris</arg>\n'
'<arg name="days">3</arg></call>',
True,
),
(
'<call name="get.weather"><arg name="city">Paris</arg>'
'<arg name="days">soon</arg></call>',
False,
),
('<call name="get.weather"><arg name="days">3</arg></call>', False),
(CALL + '<call name="ping"><arg name="any">x</arg></call>', True),
('<call name="other"></call>', False),
("<think>plan</think>The answer.", False),
],
)
def test_forced_calls_follow_the_template_format(text, accepted):
assert accepts(build(), text) is accepted
def test_single_call_when_parallel_calls_are_disabled():
tag = build(parallel=False)
assert accepts(tag, CALL)
assert not accepts(tag, CALL + CALL)
def test_thinking_is_rejected_when_not_requested():
assert not accepts(build(thinking=False), "<think>plan</think>" + CALL)
@pytest.mark.parametrize(
("text", "accepted"),
[
("<think>plan</think><answer>Sunny.", True),
("<answer>Sunny.</answer>", True),
("<think>plan</think>" + CALL, True),
("<answer>Sunny.</answer>" + CALL, False),
('<call name="get.weather"><arg name="days">soon</arg></call>', False),
],
)
def test_answer_field_lets_the_model_reply_instead(text, accepted):
assert accepts(build(ANSWER_TEMPLATE, answer=True), text) is accepted
@pytest.mark.parametrize("required", [False, True])
@pytest.mark.parametrize(
("text", "optional_only"),
[
("<think>plan</think>" + CALL, False),
("<think>\nplan</think><answer>Sunny.", False),
("<think> \n</think><answer>Sunny.", False),
(CALL, True),
("<answer>Sunny.", True),
("<think></think><answer>Sunny.", True),
("<think><</think><answer>Sunny.", True),
],
)
def test_required_reasoning_opens_the_message_with_nonempty_reasoning(
required, text, optional_only
):
fields = load_response_template(ANSWER_TEMPLATE).fields
tag = tool_call_structural_tag(
fields["tool_calls"],
[WEATHER],
parallel_tool_calls=True,
thinking=fields["thinking"],
answer=fields["content"],
message_start=["<assistant>"],
requires_reasoning=required,
)
assert accepts(tag, text) is (not required or not optional_only)
def test_answer_rest_finishes_the_open_answer():
answer = load_response_template(ANSWER_TEMPLATE).fields["content"]
tag = answer_rest_structural_tag(answer)
assert accepts(tag, "Sunny.</answer>")
assert accepts(tag, "Sunny.")
assert not accepts(tag, "Sunny.</answer>" + CALL)
def test_answer_without_literal_opener_is_not_derived():
assert build(answer=True) is None
def test_answer_holds_the_answer_format():
schema = {"type": "json_schema", "json_schema": {"type": "object"}}
with_tools = build(ANSWER_TEMPLATE, answer=True, answer_format=schema)
answer_only = build(ANSWER_TEMPLATE, (), answer=True, answer_format=schema)
for tag in (with_tools, answer_only):
assert accepts(tag, '<think>plan</think><answer>{"a": 1}</answer>')
assert not accepts(tag, "<answer>Sunny.</answer>")
assert accepts(with_tools, CALL)
assert not accepts(answer_only, CALL)
def test_json_arguments_follow_the_schema():
template = deepcopy(TEMPLATE)
template["fields"]["tool_calls"].update(content="json", content_args={})
tag = build(template, [WEATHER])
assert accepts(tag, '<call name="get.weather">{"city": "Paris"}</call>')
assert not accepts(tag, '<call name="get.weather">{"days": 3}</call>')
END_X = {"tag_pattern": r'<arg name="(?P<key>[^"]+)">(?P<value>.*?)X'}
@pytest.mark.parametrize(
"change",
[
{"content": "json", "content_args": {"unquoted_keys": True}},
{
"transform": {
"type": "function",
"function": {"name": "fixed", "arguments": "{content}"},
}
},
{"content": "kv-lines", "content_args": {}},
# Framing in which argument text can spell a closer, where the parser
# would end the call.
{"close": ["}X", "</call>"], "content_args": END_X},
{
"close": ["}X", "</call>"],
"content_args": {**END_X, "value_parser": {"name": "json"}},
},
{"close": ["7X", "</call>"], "content_args": END_X},
{"close": [">X", "</call>"]},
{"close": ["></call>", "</call>"]},
{"close": ["><a", "</call>"]},
{"close": ["}</call>", "</call>"], "content": "json", "content_args": {}},
],
)
def test_underivable_tool_formats_are_not_derived(change):
template = deepcopy(TEMPLATE)
template["fields"]["tool_calls"].update(change)
assert build(template) is None
def test_non_literal_thinking_delimiters_are_not_derived():
template = deepcopy(TEMPLATE)
del template["fields"]["thinking"]["open"]
template["fields"]["thinking"]["open_pattern"] = r"<think(?: mode=\w+)?>"
assert build(template) is None
assert build(template, thinking=False) is not None
LEVEL = {"type": "object", "properties": {"level": {"$ref": "#/$defs/level"}}}
@pytest.mark.parametrize(
("schema", "value", "accepted"),
[
({"type": "integer", "nullable": True}, "null", True),
({"type": "integer", "nullable": True}, "soon", False),
({"type": ["integer", "null"]}, "null", True),
({"anyOf": [{"type": "integer"}, {"type": "boolean"}]}, "true", True),
({"anyOf": [{"type": "integer"}, {"type": "boolean"}]}, "soon", False),
({"type": ["string", "integer"]}, "soon", True),
({"enum": ["celsius", "fahrenheit"]}, "celsius", True),
({"enum": ["celsius", "fahrenheit"]}, "kelvin", False),
({"type": "string", "const": "on"}, "off", False),
(LEVEL, '{"level": 2}', True),
(LEVEL, '{"level": "high"}', False),
],
)
def test_argument_values_follow_how_the_parser_reads_them(schema, value, accepted):
parameters = {
"type": "object",
"properties": {"v": schema},
"required": ["v"],
"$defs": {"level": {"type": "integer"}},
}
assert accepts(build(functions=[tool(parameters)]), call(("v", value))) is accepted
@pytest.mark.parametrize(
("schema", "strict", "derivable"),
[
# The parser reads values without a type as text.
({"enum": [1, 2]}, True, False),
({"$ref": "#/$defs/level"}, True, False),
({"$ref": "#/$defs/level"}, False, True),
# Raw text cannot hold its closer.
({"enum": ["a</arg>b"]}, True, False),
({"type": "object", "properties": {"x": {"$ref": "#/x"}}}, True, False),
(False, True, False),
# A match could write the start of the value's end.
({"type": "string", "pattern": "^[a-z<]+$"}, True, False),
({"type": "string", "pattern": "^[a-z<]+$"}, False, True),
({"type": "string", "pattern": "^a.b$"}, True, False),
# XGrammar holds length bounds only in JSON.
({"type": "string", "minLength": 3}, True, False),
({"type": "string", "format": "ipv6"}, True, False),
],
)
def test_schemas_text_values_cannot_hold_are_not_derived(schema, strict, derivable):
parameters = {
"type": "object",
"properties": {"v": schema},
"$defs": {"level": {"type": "integer"}},
}
assert (build(functions=[tool(parameters, strict=strict)]) is not None) is derivable
@pytest.mark.parametrize(
("schema", "value", "accepted"),
[
({"type": "string", "format": "email"}, "a.b@x.co", True),
({"type": "string", "format": "email"}, "ab", False),
({"type": "string", "format": "date-time"}, "2026-10-06T12:00:00Z", True),
({"type": "string", "format": "date-time"}, "2026-13-06T12:00:00Z", False),
({"type": "string", "pattern": "^[A-Z]{3}$"}, "ABC", True),
({"type": "string", "pattern": "^[A-Z]{3}$"}, "ABCD", False),
# Like XGrammar, the format overrides the pattern and the length bounds.
(
{"type": "string", "format": "uuid", "pattern": "^x$", "maxLength": 1},
"123e4567-e89b-12d3-a456-426614174000",
True,
),
],
)
def test_text_values_hold_string_formats_and_patterns(schema, value, accepted):
parameters = {"type": "object", "properties": {"v": schema}, "required": ["v"]}
assert accepts(build(functions=[tool(parameters)]), call(("v", value))) is accepted
def test_values_cannot_hold_a_closer():
template = deepcopy(TEMPLATE)
template["fields"]["tool_calls"]["close"] = ["</call>", "STOP"]
text = {"type": "object", "properties": {"v": {"type": "string"}}}
strings = {"type": "array", "items": {"type": "string"}}
json_text = {"type": "object", "properties": {"v": strings}}
assert accepts(build(template, [tool(text)]), call(("v", "ab")))
assert not accepts(build(template, [tool(text)]), call(("v", "aSTOPb")))
assert not accepts(
build(template, [tool(None, strict=False)]), call(("v", "aSTOPb"))
)
assert accepts(build(template, [tool(json_text)]), call(("v", '["ab"]')))
assert not accepts(build(template, [tool(json_text)]), call(("v", '["aSTOPb"]')))
def test_extra_arguments_need_additional_properties():
parameters = {"type": "object", "properties": {"city": {"type": "string"}}}
extra = call(("city", "Paris"), ("zip", "75001"))
assert not accepts(build(functions=[tool(parameters)]), extra)
# Next to declared keys, an extra one could repeat them unchecked.
allowed = {**parameters, "additionalProperties": True}
assert build(functions=[tool(allowed)]) is None
free_form = {"type": "object", "additionalProperties": True}
assert accepts(build(functions=[tool(free_form)]), extra)
def test_only_strict_tools_follow_their_schema():
off_schema = call(("days", "soon"))
parameters = WEATHER["parameters"]
assert accepts(build(functions=[tool(parameters, strict=False)]), off_schema)
assert not accepts(build(functions=[tool(parameters)]), off_schema)
assert accepts(build(functions=[tool(None)]), call())
assert not accepts(build(functions=[tool(None)]), call(("a", "x")))
def test_annotations_keep_strict_arguments_to_their_schema():
parameters = {
**WEATHER["parameters"],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weather",
}
tag = build(functions=[tool(parameters)])
assert accepts(tag, call(("city", "Paris"), ("days", "3")))
assert not accepts(tag, call(("city", "Paris"), ("days", "soon")))
@pytest.mark.parametrize(
("arguments", "accepted", "accepted_sorted"),
[
((("path", "a"), ("content", "b")), True, True),
((("content", "b"), ("path", "a")), False, True),
((("path", "a"),), True, True),
((("content", "b"),), False, False),
((("path", "a"), ("path", "c")), False, False),
((("path", "a"), ("mode", "c")), False, False),
],
)
def test_strict_arguments_follow_declared_or_sorted_order(
arguments, accepted, accepted_sorted
):
parameters = {
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
"required": ["path"],
}
tags = [
build(functions=[tool(parameters)], sorted_arguments=sorted_arguments)
for sorted_arguments in (False, True)
]
assert [accepts(tag, call(*arguments)) for tag in tags] == [
accepted,
accepted_sorted,
]
LOOKAHEAD_TEMPLATE = deepcopy(TEMPLATE)
LOOKAHEAD_TEMPLATE["fields"]["tool_calls"]["content_args"]["tag_pattern"] = (
r'<arg name="(?P<key>[^"]+)">(?P<value>.*?)\n(?:</arg>|(?=<arg name="))'
)
@pytest.mark.parametrize(
("body", "accepted_loose", "accepted_strict"),
[
('<arg name="city">Paris\n</arg>\n<arg name="days">3\n</arg>', True, True),
('<arg name="city">Paris\n<arg name="days">3\n</arg>', True, True),
('<arg name="days">3\n<arg name="city">Paris\n</arg>', True, False),
('<arg name="city">Paris\n<arg name="city">Lyon\n</arg>', True, False),
('<arg name="city">Paris\n<arg name="hours">3\n</arg>', True, False),
('<arg name="city">Paris\n<arg name="', False, False),
('<arg name="city">Paris\n<arg name="days\n</arg>', False, False),
('<arg name="city">a <arg name="b"> c\n</arg>', True, True),
],
)
def test_values_end_where_a_lookahead_end_does(body, accepted_loose, accepted_strict):
parameters = WEATHER["parameters"]
tags = [
build(LOOKAHEAD_TEMPLATE, [tool(parameters, strict=strict)])
for strict in (False, True)
]
text = f'<call name="f">{body}</call>'
assert [accepts(tag, text) for tag in tags] == [accepted_loose, accepted_strict]
@pytest.mark.parametrize("lookahead", [False, True])
@pytest.mark.parametrize(
("keys", "accepted"),
[
("b", True),
("ab", True),
("bc", True),
("bd", True),
("be", True),
("abde", True),
("bcde", True),
("", False),
("a", False),
("acd", False),
("bdc", False),
("bcc", False),
],
)
def test_last_parameter_follows_every_required_one(keys, accepted, lookahead):
parameters = {
"properties": {key: {"type": "string"} for key in "abcde"},
"required": ["b"],
}
tag = build(LOOKAHEAD_TEMPLATE, [tool(parameters)])
ends = ["\n" if lookahead else "\n</arg>"] * (len(keys) - 1) + ["\n</arg>"]
body = "".join(f'<arg name="{k}">x{e}' for k, e in zip(keys, ends))
assert accepts(tag, f'<call name="f">{body}</call>') is accepted
def test_required_argument_without_schema_is_still_required():
parameters = {"type": "object", "properties": {}, "required": ["a"]}
tag = build(functions=[tool(parameters)])
assert accepts(tag, call(("a", "x")))
assert not accepts(tag, call())
assert build(functions=[tool({**parameters, "required": ["a", 1]})]) is None
@pytest.mark.parametrize(
("function", "match"),
[
(tool({}, name="get weather"), "tool name"),
(tool({"properties": {'a"b': {"type": "string"}}}), "parameter name"),
],
)
def test_unencodable_names_are_rejected(function, match):
with pytest.raises(ValueError, match=match):
build(functions=[function])
@@ -53,6 +53,7 @@ from openai_harmony import Message as OpenAIHarmonyMessage
from pydantic import (
BeforeValidator,
Field,
PrivateAttr,
ValidationError,
field_serializer,
model_validator,
@@ -469,6 +470,14 @@ class ResponsesRequest(OpenAIBaseModel):
max_output_tokens_param="max_output_tokens",
)
_grammar_from_parser: bool = PrivateAttr(default=False)
"""CAUTION: Should only be set by the parser's adjust_request."""
_parser_structured_outputs: StructuredOutputsParams | None = PrivateAttr(
default=None
)
"""Parser-derived constraints that preserve the public request's text metadata."""
_DEFAULT_SAMPLING_PARAMS = {
"temperature": 1.0,
"top_p": 1.0,
@@ -478,6 +487,8 @@ class ResponsesRequest(OpenAIBaseModel):
def extract_structured_outputs(self) -> StructuredOutputsParams | None:
"""Normalize request constraints into ``StructuredOutputsParams``."""
if self._parser_structured_outputs is not None:
return self._parser_structured_outputs
if self.text is None or self.text.format is None:
return self.structured_outputs
@@ -526,6 +526,7 @@ class OpenAIServingResponses(GenerateBaseServing):
trace_headers=trace_headers,
session_id=session_id,
reasoning_parser_kwargs=reasoning_parser_kwargs,
reasoning_ended=True if request._grammar_from_parser else None,
)
generators.append(generator)
@@ -647,6 +648,7 @@ class OpenAIServingResponses(GenerateBaseServing):
trace_headers: Mapping[str, str] | None = None,
session_id: str | None = None,
reasoning_parser_kwargs: dict[str, Any] | None = None,
reasoning_ended: bool | None = None,
):
max_model_len = self.model_config.max_model_len
cache_salt = cast(str | None, engine_input.get("cache_salt"))
@@ -673,6 +675,7 @@ class OpenAIServingResponses(GenerateBaseServing):
priority=priority,
session_id=session_id,
reasoning_parser_kwargs=reasoning_parser_kwargs,
reasoning_ended=reasoning_ended,
)
async for res in generator:
+6
View File
@@ -246,6 +246,7 @@ if TYPE_CHECKING:
VLLM_MAX_TOKENS_PER_EXPERT_FP4_MOE: int = 163840
VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS: int = 1
VLLM_ENFORCE_STRICT_TOOL_CALLING: bool = True
VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT: bool = True
VLLM_MQ_MAX_CHUNK_BYTES_MB: int = 16
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS: int = 300
VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS: int = 5
@@ -1844,6 +1845,11 @@ environment_variables: dict[str, Callable[[], Any]] = {
"VLLM_ENFORCE_STRICT_TOOL_CALLING": lambda: (
os.getenv("VLLM_ENFORCE_STRICT_TOOL_CALLING", "True").lower() in ("true", "1")
),
# Constrain every `beam` output to its response template's format, also
# without tool or structured output constraints.
"VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT": lambda: (
os.getenv("VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT", "True").lower() in ("true", "1")
),
# Control the max chunk bytes (in MB) for the rpc message queue.
# Object larger than this threshold will be broadcast to worker
# processes via zmq.
+262
View File
@@ -0,0 +1,262 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Parser for Beam checkpoints."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, cast
from openai.types.responses import ToolChoiceFunction
from vllm import envs
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionNamedToolChoiceParam,
ChatCompletionRequest,
)
from vllm.exceptions import VLLMValidationError
from vllm.logger import init_logger
from vllm.parser.abstract_parser import (
_xgrammar_supports,
structured_outputs_to_format,
)
from vllm.parser.chat_parsing import ResponseParser
from vllm.parser.chat_parsing.response_templates import ResponseTemplate
from vllm.parser.response_template import (
CONTENT_FIELD,
THINKING_FIELD,
TOOL_FIELD,
ResponseTemplateParser,
ResponseTemplateReasoningParser,
ResponseTemplateToolParser,
_as_tool_dicts,
_is_named_tool_choice,
_tool_parsing_enabled,
)
from vllm.parser.response_template_grammar import (
_openers,
answer_rest_structural_tag,
message_start_anchors,
tool_call_structural_tag,
)
from vllm.sampling_params import StructuredOutputsParams
from vllm.tool_parsers.structural_tag_registry import resolve_tool_strictness
if TYPE_CHECKING:
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
logger = init_logger(__name__)
def _named_tool(request: ChatCompletionRequest | ResponsesRequest) -> str:
choice = request.tool_choice
if isinstance(choice, ToolChoiceFunction):
return choice.name
return cast(ChatCompletionNamedToolChoiceParam, choice).function.name
def _starts_message(template: ResponseTemplate, prefix: str) -> bool:
"""Whether output after `prefix` starts the assistant message."""
ends = [match.end() for match in template.start_anchor_re.finditer(prefix)]
return bool(ends) and ends[-1] == len(prefix)
def _open_field(template: ResponseTemplate, prefix: str) -> str | None:
"""The field that `prefix` leaves open with its explicit opener, if any."""
field = None
for event in ResponseParser(template, prefix=prefix).initial_events:
if event["type"] == "region_open" and event["end"] > event["start"]:
field = event["field"]
elif event["type"] in {"region_close", "region_malformed"}:
field = None
return field
class BeamReasoningParser(ResponseTemplateReasoningParser):
"""Selects `BeamParser` as the reasoning parser."""
@classmethod
def unified_parser_cls(cls) -> type[ResponseTemplateParser]:
return BeamParser
@property
def reasoning_start_str(self) -> str | None:
if literal := super().reasoning_start_str:
return literal
thinking = self.response_template.fields.get(THINKING_FIELD)
if thinking is None:
return None
openers = _openers(thinking, message_start_anchors(self.response_template))
if not openers:
return None
# Budget tracking needs one marker for both a prompt continuation
# and a complete assistant message in the checkpoint's pattern.
marker = min(openers, key=len)
return marker if all(opener.endswith(marker) for opener in openers) else None
class BeamToolParser(ResponseTemplateToolParser):
"""Selects `BeamParser` as the tool parser."""
@classmethod
def unified_parser_cls(cls) -> type[ResponseTemplateParser]:
return BeamParser
class BeamParser(ResponseTemplateParser):
"""Parse Beam output with the checkpoint's `response_template` metadata,
and constrain it with a grammar derived from that template."""
reasoning_parser_cls = BeamReasoningParser
tool_parser_cls = BeamToolParser
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
chat_template_kwargs = kwargs.get("chat_template_kwargs") or {}
# From the message start, the grammar opens the message with non-empty
# reasoning, unless the request turns thinking off.
self._requires_reasoning = bool(
chat_template_kwargs.get("enable_thinking", True)
)
def _constrain_request(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> ChatCompletionRequest | ResponsesRequest:
message_start = _starts_message(self.response_template, self._prefix)
tag = self._template_tag(request, message_start=message_start)
if tag is not None:
grammar = StructuredOutputsParams(structural_tag=json.dumps(tag))
if isinstance(request, ChatCompletionRequest):
request.structured_outputs = grammar
request.response_format = None
else:
request._parser_structured_outputs = grammar
# From the message start, the tag also covers the reasoning.
request._grammar_from_parser = message_start
return request
def _template_tag(
self,
request: ChatCompletionRequest | ResponsesRequest,
*,
message_start: bool,
) -> dict[str, Any] | None:
"""The template grammar for `request`, or `None` to leave its output
to the request's own constraint, if any."""
fields = self.response_template.fields
named = _is_named_tool_choice(request)
tools_enabled = (
self._tool_parser is not None
and bool(request.tools)
and _tool_parsing_enabled(
request, enable_auto_tools=self._enable_auto_tools
)
)
forced = tools_enabled and (request.tool_choice == "required" or named)
# The prompt may still list the tools, so only the grammar keeps the
# model from calling them.
tools_forbidden = (
bool(request.tools)
and request.tool_choice == "none"
and TOOL_FIELD in fields
)
constrained_tools = None
if tools_enabled and (forced or envs.VLLM_ENFORCE_STRICT_TOOL_CALLING):
assert self._tool_parser is not None
strict_level = self.tool_strict_level
if strict_level is None:
strict_level = self._tool_parser.default_tool_strict_level
constrained_tools = resolve_tool_strictness(
request.tools or [], request.tool_choice, strict_level
)
output = request.extract_structured_outputs()
if forced and output is not None:
logger.warning_once(
"structured outputs are ignored because tool_choice forces tool call."
)
output = None
answer = fields.get(CONTENT_FIELD)
if output is not None and (
output.structural_tag is not None
or answer is None
or answer.open_re is None
or not _xgrammar_supports(output)
):
if constrained_tools is not None:
logger.warning_once(
"response_template parsing cannot enforce strict tools "
"alongside this structured output."
)
return None
# Without a tool or structured output constraint, the output still
# follows the template.
always_constrain = envs.VLLM_BEAM_ALWAYS_CONSTRAIN_OUTPUT and not forced
if (
constrained_tools is None
and output is None
and not tools_forbidden
and not always_constrain
):
return None
# Inside an open answer, only the always-on grammar applies, finishing a
# free answer. Inside open reasoning, the grammar needs the reasoning
# gate to defer it to the reasoning's end.
open_field = _open_field(self.response_template, self._prefix)
reasoning_gated = (
self._reasoning_parser is not None and request.include_reasoning
)
tag = None
if open_field == CONTENT_FIELD:
if always_constrain and output is None:
tag = answer_rest_structural_tag(fields[CONTENT_FIELD])
elif open_field != THINKING_FIELD or reasoning_gated:
functions = []
if tools_enabled:
tools = _as_tool_dicts(constrained_tools or request.tools)
functions = [tool.get("function", tool) for tool in tools]
if named:
name = _named_tool(request)
functions = [f for f in functions if f.get("name") == name]
answer_format = (
None if output is None else structured_outputs_to_format(output)
)
try:
tag = tool_call_structural_tag(
fields.get(TOOL_FIELD),
functions,
parallel_tool_calls=(
not named and request.parallel_tool_calls is not False
),
thinking=fields.get(THINKING_FIELD),
answer=None if forced else answer,
answer_format=(
None if answer_format is None else answer_format.model_dump()
),
message_start=(
message_start_anchors(self.response_template)
if message_start
else None
),
requires_reasoning=self._requires_reasoning,
# Beam drifts from schemas the grammar does not enforce,
# and also writes arguments sorted by name.
sorted_arguments=True,
strict_arguments=True,
)
except ValueError as exc:
raise VLLMValidationError(str(exc), parameter="tools") from exc
if tag is None and forced:
kind = "named" if named else "required"
raise VLLMValidationError(
f"response_template parsing cannot guarantee {kind} tool choice "
"for this request",
parameter="tool_choice",
)
if tag is None and constrained_tools is not None:
logger.warning_once(
"response_template parsing cannot enforce strict tools for this "
"request."
)
return tag
+33 -32
View File
@@ -16,8 +16,6 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
HF_PARSER = "hf"
class ParserManager:
"""Provides a unified Parser by composing reasoning and tool parser adapters."""
@@ -122,8 +120,10 @@ class ParserManager:
tags (``--tool-strict-level``), or ``None`` for the tool
parser's default level.
tokenizer: Tokenizer the composed parser is validated against
at startup (see :meth:`validate_parser_tokenizer`). ``None``
skips the check (e.g. ``skip_tokenizer_init``).
at startup (see :meth:`validate_parser_tokenizer`), including
its `response_template` metadata when a response-template
parser is selected. ``None`` skips the check (e.g.
``skip_tokenizer_init``).
Returns:
A Parser class, or None if neither parser is specified.
@@ -184,39 +184,40 @@ class ParserManager:
HarmonyParser.tool_strict_level = strict_level
return HarmonyParser
if HF_PARSER in (reasoning_parser_name, tool_parser_name):
if {reasoning_parser_name, tool_parser_name} - {
HF_PARSER,
None,
"",
}:
raise TypeError(
"The hf parser cannot be combined with other "
"reasoning or tool call parsers"
)
from vllm.parser.response_template import (
ResponseTemplateParser,
validate_tokenizer_response_template,
)
from vllm.parser.response_template import (
response_template_parser_cls,
validate_tokenizer_response_template,
)
template_parsers = {
response_template_parser_cls(parser_cls)
for parser_cls in (reasoning_parser_cls, tool_parser_cls)
if parser_cls is not None
}
template_parser_cls = next(iter(template_parsers - {None}), None)
if template_parser_cls is not None:
if len(template_parsers) > 1:
raise TypeError(
f"--reasoning-parser {reasoning_parser_name} and "
f"--tool-call-parser {tool_parser_name} cannot be combined"
)
if tokenizer is not None:
validate_tokenizer_response_template(
tokenizer,
reasoning=reasoning_parser_cls is not None,
tools=tool_parser_cls is not None,
reasoning_parser=reasoning_parser_name,
# Without auto tool choice, no tool parser is selected.
tool_parser=tool_parser_name if tool_parser_cls else None,
)
r_cls = reasoning_parser_cls
t_cls = tool_parser_cls
auto_tools = enable_auto_tools
class _ResponseTemplateParser(ResponseTemplateParser):
reasoning_parser_cls = r_cls
tool_parser_cls = t_cls
tool_strict_level = strict_level
_enable_auto_tools = auto_tools
return _ResponseTemplateParser
return type(
template_parser_cls.__name__,
(template_parser_cls,),
{
"reasoning_parser_cls": reasoning_parser_cls,
"tool_parser_cls": tool_parser_cls,
"tool_strict_level": strict_level,
"_enable_auto_tools": enable_auto_tools,
},
)
if reasoning_parser_name == "kimi_k3" or tool_parser_name == "kimi_k3":
from vllm.parser.kimi_k3 import KimiK3Parser
+151 -15
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import copy
import json
from collections.abc import Callable, Iterable, Sequence
from math import inf
from typing import TYPE_CHECKING, Any, cast
from openai.types.responses import ToolChoiceCustom, ToolChoiceFunction
@@ -28,6 +29,7 @@ from vllm.parser.chat_parsing.response_templates import (
ResponseTemplate,
load_response_template,
)
from vllm.parser.response_template_grammar import message_start_anchors
from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
from vllm.renderers.chat_utils import make_tool_call_id
from vllm.tool_parsers.abstract_tool_parser import ToolParser
@@ -135,27 +137,38 @@ def validate_response_template_for_serving(
def validate_tokenizer_response_template(
tokenizer: Any,
*,
reasoning: bool,
tools: bool,
reasoning_parser: str | None,
tool_parser: str | None,
) -> None:
"""Fail at startup if the tokenizer cannot serve the requested parsing."""
"""Fail at startup if the tokenizer cannot serve the selected parsers,
named as given on the command line."""
template = resolve_response_template(tokenizer)
if template is None:
selected = " ".join(
f"{flag} {name}"
for flag, name in (
("--reasoning-parser", reasoning_parser),
("--tool-call-parser", tool_parser),
)
if name
)
raise TypeError(
"The hf parser requires `response_template` metadata "
f"{selected} requires `response_template` metadata "
"in the tokenizer configuration"
)
try:
fields = validate_response_template_for_serving(template).fields
except (TypeError, ValueError) as exc:
raise TypeError(f"Invalid response_template metadata: {exc}") from exc
if reasoning and THINKING_FIELD not in fields:
if reasoning_parser and THINKING_FIELD not in fields:
raise TypeError(
"--reasoning-parser hf requires a response_template with a thinking field"
f"--reasoning-parser {reasoning_parser} requires a response_template "
"with a thinking field"
)
if tools and TOOL_FIELD not in fields:
if tool_parser and TOOL_FIELD not in fields:
raise TypeError(
"--tool-call-parser hf requires a response_template with a tool_calls field"
f"--tool-call-parser {tool_parser} requires a response_template "
"with a tool_calls field"
)
@@ -292,7 +305,7 @@ def _serving_template(
template = resolve_response_template(tokenizer, template)
if template is None:
raise ValueError(
"The hf parser requires `response_template` "
"Parsing with a response template requires `response_template` "
"metadata in the tokenizer configuration"
)
validate_response_template_for_serving(template)
@@ -319,12 +332,17 @@ def _to_tool_calls(value: Any) -> list[tuple[str, str]]:
class ResponseTemplateReasoningParser(ReasoningParser):
"""Reasoning end detection for `--reasoning-parser hf`.
"""Reasoning end detection for a response-template parser.
`ResponseTemplateParser` parses the output; this parser tells it and the
structured-output engine where reasoning ends.
"""
@classmethod
def unified_parser_cls(cls) -> type[ResponseTemplateParser]:
"""The unified parser that selecting this reasoning parser stands for."""
return ResponseTemplateParser
def __init__(
self,
tokenizer: TokenizerLike,
@@ -400,8 +418,26 @@ class ResponseTemplateReasoningParser(ReasoningParser):
class ResponseTemplateToolParser(ToolParser):
"""Registers `--tool-call-parser hf`; `ResponseTemplateParser` extracts the
tool calls."""
"""Selects a response-template parser as the tool parser;
`ResponseTemplateParser` extracts the tool calls."""
@classmethod
def unified_parser_cls(cls) -> type[ResponseTemplateParser]:
"""The unified parser that selecting this tool parser stands for."""
return ResponseTemplateParser
def response_template_parser_cls(
split_parser_cls: type[ReasoningParser] | type[ToolParser],
) -> type[ResponseTemplateParser] | None:
"""The response-template parser that a registered reasoning or tool parser
stands for, or `None` for any other parser."""
if issubclass(
split_parser_cls,
(ResponseTemplateReasoningParser, ResponseTemplateToolParser),
):
return split_parser_cls.unified_parser_cls()
return None
class _DeltaBuilder:
@@ -441,23 +477,50 @@ class _ResponseStream:
self.include_reasoning = include_reasoning
self.parse_tools = parse_tools
self.new_tool_call_id = new_tool_call_id
self.template = template
self.parser = ResponseParser(template, prefix=prefix, tools=tools)
self._prefix_end = len(self.parser.input_text)
self._next_tool_index = 0
self._finalized = False
self._open_region: dict[str, Any] | None = None
self._track(self.parser.initial_events)
def feed(self, text: str, *, finished: bool) -> DeltaMessage | None:
if self._finalized:
return None
delta = _DeltaBuilder()
self._route(self.parser.feed(text), delta)
self._route(self._track(self.parser.feed(text)), delta)
if finished:
self._finalized = True
if len(self.parser.input_text) > self._prefix_end:
_, events = self.parser.finalize()
self._route(events, delta)
events = self.parser.feed(self._closer_rest())
self._route(events + self.parser.finalize()[1], delta)
return delta.build()
def _track(self, events: list[dict[str, Any]]) -> list[dict[str, Any]]:
for event in events:
if event["type"] == "region_open":
self._open_region = event
elif event["type"] in ("region_close", "region_malformed"):
self._open_region = None
return events
def _closer_rest(self) -> str:
"""The rest of a closer the output stopped in after ending its region,
when only the next message's start anchor is missing."""
if self._open_region is None:
return ""
field = self.template.fields[self._open_region["field"]]
text = self.parser.input_text[self._open_region["end"] :]
for closer in field.close_literals or ():
for anchor in message_start_anchors(self.template):
if not anchor or not closer.endswith(anchor) or closer == anchor:
continue
for cut in range(len(closer) - 1, len(closer) - len(anchor) - 1, -1):
if text.endswith(closer[:cut]):
return closer[cut:]
return ""
def _route(self, events: Sequence[dict[str, Any]], delta: _DeltaBuilder) -> None:
for event in events:
field = event.get("field")
@@ -499,6 +562,54 @@ class _ResponseStream:
self._next_tool_index += 1
class _ReasoningTokenCounter:
"""Counts the reasoning tokens of one growing generation, parsing each
token once."""
def __init__(
self, template: ResponseTemplate, prefix: str, tokenizer: TokenizerLike
) -> None:
self.tokenizer = tokenizer
self.parser = ResponseParser(template, prefix=prefix)
self.start = len(self.parser.input_text)
self.token_ids: list[int] = []
self.token_ends: list[int] = []
# Input spans between the delimiters of thinking regions.
self.spans: list[tuple[int, float]] = []
self.open: int | None = None
self._track(self.parser.initial_events)
def continues(self, token_ids: Sequence[int]) -> bool:
return list(token_ids[: len(self.token_ids)]) == self.token_ids
def count(self, token_ids: Sequence[int]) -> int:
new_ids = list(token_ids[len(self.token_ids) :])
texts = [_decode(self.tokenizer, [token_id]) for token_id in new_ids]
end = self.token_ends[-1] if self.token_ends else self.start
for text in texts:
end += len(text)
self.token_ends.append(end)
self.token_ids.extend(new_ids)
self._track(self.parser.feed("".join(texts)))
# Text held back as a possible closer counts until a closer arrives.
spans = self.spans if self.open is None else [*self.spans, (self.open, inf)]
count, start = 0, self.start
for end in self.token_ends:
count += any(begin < end and start < close for begin, close in spans)
start = end
return count
def _track(self, events: Iterable[dict[str, Any]]) -> None:
for event in events:
if event["field"] != THINKING_FIELD or event["type"] == "region_chunk":
continue
if event["type"] == "region_open":
self.open = event["end"]
elif self.open is not None:
self.spans.append((self.open, event["start"]))
self.open = None
class ResponseTemplateParser(Parser):
"""Parse reasoning, content, and tool calls with one response template."""
@@ -526,6 +637,7 @@ class ResponseTemplateParser(Parser):
self._prompt_token_ids: list[int] | None = None
self._prefix = ""
self._stream: _ResponseStream | None = None
self._reasoning_counter: _ReasoningTokenCounter | None = None
def set_prompt_token_ids(self, prompt_token_ids: Sequence[int]) -> None:
prompt_token_ids = list(prompt_token_ids)
@@ -534,6 +646,7 @@ class ResponseTemplateParser(Parser):
self._prompt_token_ids = prompt_token_ids
self._prefix = _decode(self.model_tokenizer, prompt_token_ids)
self._stream = None
self._reasoning_counter = None
def adjust_request(
self,
@@ -547,6 +660,14 @@ class ResponseTemplateParser(Parser):
request.skip_special_tokens = False
if hasattr(request, "spaces_between_special_tokens"):
request.spaces_between_special_tokens = False
return self._constrain_request(request)
def _constrain_request(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> ChatCompletionRequest | ResponsesRequest:
"""Constrain `request` to the tool guarantees it asks for, or reject
those this parser cannot enforce."""
if (
self._tool_parser is not None
and request.tools
@@ -567,6 +688,21 @@ class ResponseTemplateParser(Parser):
or self._reasoning_parser.is_reasoning_end(input_ids)
)
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
"""Count the generated tokens whose text the template parses as
reasoning, continuing reasoning left open by the prompt."""
if (
self._reasoning_parser is None
or THINKING_FIELD not in self.response_template.fields
):
return 0
counter = self._reasoning_counter
if counter is None or not counter.continues(token_ids):
counter = self._reasoning_counter = _ReasoningTokenCounter(
self.response_template, self._prefix, self.model_tokenizer
)
return counter.count(token_ids)
def parse(
self,
model_output: str,
File diff suppressed because it is too large Load Diff
+4
View File
@@ -84,6 +84,10 @@ _REASONING_PARSERS_TO_REGISTER = {
"response_template_reasoning_parser",
"ResponseTemplateReasoningParser",
),
"beam": (
"beam_reasoning_parser",
"BeamReasoningParser",
),
"holo2": (
"deepseek_v3_reasoning_parser",
"DeepSeekV3ReasoningWithThinkingParser",
+6
View File
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.parser.beam import BeamReasoningParser
__all__ = ["BeamReasoningParser"]
+8 -3
View File
@@ -60,6 +60,7 @@ from vllm.renderers.chat_utils import (
has_non_text_content,
)
from vllm.renderers.inputs.preprocess import (
extract_prompt_components,
parse_model_prompt,
prompt_to_seq,
)
@@ -844,13 +845,17 @@ class OnlineRenderer:
f"but got {type(request).__name__}"
)
raise NotImplementedError(msg)
request = parser(
request_parser = parser(
tokenizer,
request.tools,
model_config=self.model_config,
chat_template_kwargs=chat_params.chat_template_kwargs,
).adjust_request(
request=request,
)
prompt_token_ids = extract_prompt_components(
self.model_config, engine_input
).token_ids
if prompt_token_ids is not None:
request_parser.set_prompt_token_ids(prompt_token_ids)
request = request_parser.adjust_request(request=request)
return conversation, [engine_input]
+4
View File
@@ -90,6 +90,10 @@ _TOOL_PARSERS_TO_REGISTER = {
"response_template_tool_parser",
"ResponseTemplateToolParser",
),
"beam": (
"beam_tool_parser",
"BeamToolParser",
),
"poolside_v1": (
"poolside_v1_tool_parser",
"PoolsideV1ToolParser",
+6
View File
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.parser.beam import BeamToolParser
__all__ = ["BeamToolParser"]