Compare commits

...
Author SHA1 Message Date
Bugen Zhao c716d13203 add json passthrough support
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-07 11:28:29 +08:00
Bugen Zhao 29d9da5829 extract convert_params
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-06 21:44:09 +08:00
Bugen Zhao 7acfc161ec add tool schema resolve
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-06 16:59:48 +08:00
Bugen Zhao 0578b70ea9 rename wrapper to section
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-03 21:47:54 +08:00
Bugen Zhao 9ca4fb8bdb introduce config-driven parser
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-03 19:05:26 +08:00
7 changed files with 1763 additions and 46 deletions
+2 -2
View File
@@ -129,9 +129,9 @@ extend-exclude = ["tests/models/fixtures/*", "tests/prompts/*", "tests/tokenizer
"docs/governance/process.md", "docs/assets/contributing/vllm_bench_serve_timeline.html",
"tests/v1/engine/test_fast_incdec_prefix_err.py", ".git/*", "csrc/cpu/sgl-kernels/*",
"rust/src/chat/src/renderer/deepseek_v32/fixtures/*",
"rust/src/parser/src/tool/gemma4.rs", "rust/src/parser/src/unified/gemma4.rs",
"rust/src/parser/*",
"rust/src/text/src/output/decoded.rs",
"rust/src/tokenizer/src/incremental.rs", "rust/src/parser/src/reasoning/tests.rs"]
"rust/src/tokenizer/src/incremental.rs",]
ignore-hidden = false
[tool.typos.default]
+48 -30
View File
@@ -3,7 +3,7 @@ use std::time::Duration;
use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main};
use vllm_parser::tool::test_utils::{split_by_chars, test_tools};
use vllm_parser::tool::{Tool, ToolParser};
use vllm_parser::unified::Gemma4UnifiedParser;
use vllm_parser::unified::{Gemma4ConfigDrivenParser, Gemma4UnifiedParser};
mod utils;
use utils::{UnifiedToolParserAdapter, feed_parser};
@@ -68,14 +68,22 @@ fn long_tool_argument_fixture() -> String {
)
}
type MakeParser = fn(&[Tool]) -> Box<dyn ToolParser>;
fn parser(tools: &[Tool]) -> Box<dyn ToolParser> {
UnifiedToolParserAdapter::<Gemma4UnifiedParser>::create(tools)
.expect("Gemma4 unified parser should initialize")
}
fn config_driven_parser(tools: &[Tool]) -> Box<dyn ToolParser> {
UnifiedToolParserAdapter::<Gemma4ConfigDrivenParser>::create(tools)
.expect("Gemma4 config-driven parser should initialize")
}
fn run_stream_group(
c: &mut Criterion,
name: &str,
make_parser: MakeParser,
tools: &[Tool],
text: &str,
chunk_chars: usize,
@@ -91,7 +99,7 @@ fn run_stream_group(
group.throughput(Throughput::Bytes(text.len() as u64));
group.bench_function("reuse_parser", |b| {
let mut parser = parser(tools);
let mut parser = make_parser(tools);
b.iter(|| {
let result = feed_parser(&mut *parser, black_box(&chunks));
debug_assert_eq!(result.0, expected_normal_text);
@@ -102,7 +110,7 @@ fn run_stream_group(
group.bench_function("create_parser", |b| {
b.iter_batched(
|| parser(tools),
|| make_parser(tools),
|mut parser| {
let result = feed_parser(&mut *parser, black_box(&chunks));
debug_assert_eq!(result.0, expected_normal_text);
@@ -122,35 +130,45 @@ fn bench_gemma4(c: &mut Criterion) {
let long_tool_argument = long_tool_argument_fixture();
let long_normal_text = long_normal_text_fixture();
run_stream_group(
c,
"gemma4/mixed_complex_tool_call",
&tools,
&mixed_text,
CHUNK_CHARS,
"I will inspect the data before answering.\n Finished.",
2,
);
let variants: [(&str, MakeParser); 2] = [
("gemma4", parser),
("gemma4_config_driven", config_driven_parser),
];
run_stream_group(
c,
"gemma4/long_tool_argument",
&tools,
&long_tool_argument,
CHUNK_CHARS,
"I will write the file.\nDone.",
1,
);
for (prefix, make_parser) in variants {
run_stream_group(
c,
&format!("{prefix}/mixed_complex_tool_call"),
make_parser,
&tools,
&mixed_text,
CHUNK_CHARS,
"I will inspect the data before answering.\n Finished.",
2,
);
run_stream_group(
c,
"gemma4/long_normal_text",
&tools,
&long_normal_text,
CHUNK_CHARS,
&long_normal_text,
0,
);
run_stream_group(
c,
&format!("{prefix}/long_tool_argument"),
make_parser,
&tools,
&long_tool_argument,
CHUNK_CHARS,
"I will write the file.\nDone.",
1,
);
run_stream_group(
c,
&format!("{prefix}/long_normal_text"),
make_parser,
&tools,
&long_normal_text,
CHUNK_CHARS,
&long_normal_text,
0,
);
}
}
criterion_group!(benches, bench_gemma4);
+1
View File
@@ -28,6 +28,7 @@ pub use json::{
pub use kimi_k2::KimiK2ToolParser;
pub use minimax_m2::MinimaxM2ToolParser;
pub use minimax_m3::MinimaxM3ToolParser;
pub use parameters::{ToolSchema, ToolSchemas};
pub use qwen_coder::Qwen3CoderToolParser;
use serde::{Deserialize, Serialize};
use serde_json::Value;
+28 -14
View File
@@ -6,7 +6,7 @@ use crate::tool::Tool;
/// Normalized parameter schemas for all tools in one request.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct ToolSchemas {
pub struct ToolSchemas {
tools: BTreeMap<String, ToolSchema>,
}
@@ -17,7 +17,7 @@ pub(super) struct ToolSchemas {
/// coercing raw string parameter values into more specific JSON types for
/// downstream tool call execution.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct ToolSchema {
pub struct ToolSchema {
params: BTreeMap<String, JsonParamType>,
}
@@ -25,7 +25,7 @@ pub(super) struct ToolSchema {
///
/// It can be either a raw text string, or a structured input with named child elements.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ParamInput {
pub(crate) enum ParamInput {
Text(String),
#[allow(dead_code)]
Elements(Vec<ParamElement>),
@@ -39,7 +39,7 @@ impl From<String> for ParamInput {
/// One named structured parameter child.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ParamElement {
pub(crate) struct ParamElement {
pub name: String,
pub value: ParamInput,
}
@@ -64,7 +64,7 @@ pub(super) enum JsonParamType {
impl ToolSchemas {
/// Normalize OpenAI-style tool parameter JSON schemas for one request.
pub(super) fn from_tools(tools: &[Tool]) -> Self {
pub(crate) fn from_tools(tools: &[Tool]) -> Self {
let tools = tools
.iter()
.map(|tool| (tool.name.clone(), ToolSchema::from_schema(&tool.parameters)))
@@ -73,6 +73,14 @@ impl ToolSchemas {
Self { tools }
}
/// Resolve the parameter schema for one named tool.
///
/// Unknown tool names resolve to the empty schema, so all parameters fall
/// back to strings or object-like JSON for structured inputs.
pub(crate) fn resolve(&self, function_name: &str) -> &ToolSchema {
self.tools.get(function_name).unwrap_or(ToolSchema::empty())
}
/// Convert parameter values for one named tool.
///
/// Unknown tool names use an empty schema, so all parameters fall back to
@@ -85,13 +93,7 @@ impl ToolSchemas {
where
P: Into<ParamInput>,
{
let tool_schema = self.tools.get(function_name).unwrap_or(ToolSchema::empty());
let mut converted = Map::with_capacity(params.len());
for (name, value) in params {
let value = tool_schema.convert(&name, value.into());
converted.insert(name, value);
}
converted
self.resolve(function_name).convert_params(params)
}
/// Convert one parameter value for one named tool.
@@ -104,12 +106,24 @@ impl ToolSchemas {
where
P: Into<ParamInput>,
{
let tool_schema = self.tools.get(function_name).unwrap_or(ToolSchema::empty());
tool_schema.convert(name, value.into())
self.resolve(function_name).convert(name, value.into())
}
}
impl ToolSchema {
/// Convert a set of named parameter values with this schema.
pub(crate) fn convert_params<P>(&self, params: Vec<(String, P)>) -> Map<String, Value>
where
P: Into<ParamInput>,
{
let mut converted = Map::with_capacity(params.len());
for (name, value) in params {
let value = self.convert(&name, value.into());
converted.insert(name, value);
}
converted
}
/// Return an empty schema with no parameter information, which causes all
/// parameters to be treated as strings.
const fn empty() -> &'static Self {
@@ -0,0 +1,875 @@
//! Gemma4 format for the config-driven unified parser.
//!
//! Original Python implementation:
//! <https://github.com/vllm-project/vllm/blob/main/vllm/parser/gemma4.py>
//!
//! Handles Gemma4 reasoning and function-call formats:
//!
//! `<|channel>thought\nreasoning<channel|>`
//!
//! `<|tool_call>call:func_name{key:<|"|>value<|"|>}<tool_call|>`
use serde_json::{Map, Number, Value};
use winnow::ascii::multispace0 as ws0;
use winnow::combinator::{alt, delimited, eof, opt, separated, seq, terminated};
use winnow::error::{ContextError, ErrMode, ModalResult};
use winnow::prelude::*;
use winnow::stream::Stream;
use winnow::token::{literal, take_till, take_until};
use super::{ArgsScan, ArgsStep, ConfigDrivenParser, Input, ParserFormat};
use crate::tool::ToolSchema;
use crate::utils::{incomplete, partial_prefix_len};
const CHANNEL_START: &str = "<|channel>";
const CHANNEL_END: &str = "<channel|>";
const REASONING_START: &str = "<|channel>thought\n";
const TOOL_CALL_START: &str = "<|tool_call>";
const TOOL_CALL_END: &str = "<tool_call|>";
const STRING_DELIM: &str = "<|\"|>";
const CALL_PREFIX: &str = "call:";
/// Zero-sized marker type providing the Google Gemma4 format.
pub struct Gemma4Format;
impl ParserFormat for Gemma4Format {
const NAME: &'static str = "Gemma4";
const REASONING_START: Option<&'static str> = Some(REASONING_START);
const REASONING_END: Option<&'static str> = Some(CHANNEL_END);
const CALL_START: &'static str = TOOL_CALL_START;
const CALL_END: &'static str = TOOL_CALL_END;
const REASONING_BOUNDARY_TOKENS: Option<(&'static str, &'static str)> =
Some((CHANNEL_START, CHANNEL_END));
type ArgsScan = Gemma4ArgsScan;
fn tool_header(input: &mut Input<'_>) -> ModalResult<String> {
let (name,) = seq!(
_: literal(CALL_PREFIX),
gemma4_tool_name,
_: literal("{"),
)
.parse_next(input)?;
Ok(name)
}
fn tool_args(_schema: &ToolSchema, body: &str) -> ModalResult<Map<String, Value>> {
let Some(args_input) = body.strip_suffix('}') else {
return Err(ErrMode::Cut(ContextError::new()));
};
parse_gemma4_args(args_input)
}
}
/// Config-driven unified parser for Google Gemma4 models.
///
/// Arguments are emitted only after a full Gemma4 tool call is parsed.
pub type Gemma4ConfigDrivenParser = ConfigDrivenParser<Gemma4Format>;
/// Resumable Gemma4 argument scanner: locates the call end marker outside
/// `<|"|>`-delimited strings, so marker-shaped literals inside string values
/// do not terminate the call early.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Gemma4ArgsScan {
scanned_len: usize,
in_string: bool,
}
impl ArgsScan for Gemma4ArgsScan {
fn scan<'i>(&mut self, input: &mut Input<'i>, end: &str) -> ModalResult<ArgsStep<'i>> {
let text = **input;
if self.scanned_len > text.len() {
return incomplete();
}
loop {
let rest = &text[self.scanned_len..];
if self.in_string {
let Some(string_delim) = rest.find(STRING_DELIM) else {
self.scanned_len = safe_scan_len(text, self.scanned_len, &[STRING_DELIM]);
return incomplete();
};
self.scanned_len += string_delim + STRING_DELIM.len();
self.in_string = false;
continue;
}
let next_string_delim = rest.find(STRING_DELIM);
let next_call_end = rest.find(end);
match (next_string_delim, next_call_end) {
(Some(string_delim), Some(call_end)) if call_end < string_delim => {
let body_end = self.scanned_len + call_end;
self.scanned_len = body_end + end.len();
input.next_slice(self.scanned_len);
return Ok(ArgsStep::Buffered {
body: &text[..body_end],
});
}
(Some(string_delim), _) => {
self.scanned_len += string_delim + STRING_DELIM.len();
self.in_string = true;
}
(None, Some(call_end)) => {
let body_end = self.scanned_len + call_end;
self.scanned_len = body_end + end.len();
input.next_slice(self.scanned_len);
return Ok(ArgsStep::Buffered {
body: &text[..body_end],
});
}
(None, None) => {
self.scanned_len = safe_scan_len(text, self.scanned_len, &[STRING_DELIM, end]);
return incomplete();
}
}
}
}
}
/// Return the scan length while holding back a split marker prefix.
fn safe_scan_len(text: &str, start: usize, markers: &[&str]) -> usize {
let max_partial = markers
.iter()
.map(|marker| partial_prefix_len(&text[start..], marker))
.max()
.unwrap_or(0);
text.len() - max_partial
}
/// Parse a Gemma4 tool name.
fn gemma4_tool_name(input: &mut Input<'_>) -> ModalResult<String> {
let name = take_until(1.., "{").parse_next(input)?.trim();
if name.is_empty() {
return Err(ErrMode::Cut(ContextError::new()));
}
Ok(name.to_string())
}
/// Parse complete Gemma4 custom key-value arguments.
fn parse_gemma4_args(args: &str) -> ModalResult<Map<String, Value>> {
let mut input = args;
terminated(gemma4_args, eof).parse_next(&mut input)
}
/// Parse Gemma4's custom key-value argument object content.
fn gemma4_args(input: &mut &str) -> ModalResult<Map<String, Value>> {
let pairs: Vec<(String, Value)> = delimited(
ws0,
terminated(
separated(0.., gemma4_pair, comma_separator),
opt(comma_separator),
),
ws0,
)
.parse_next(input)?;
Ok(pairs.into_iter().collect())
}
/// Parse a Gemma4 key-value pair.
fn gemma4_pair(input: &mut &str) -> ModalResult<(String, Value)> {
let (key, value) = seq!(
_: ws0,
gemma4_key,
_: ws0,
_: literal(":"),
_: ws0,
gemma4_value,
)
.parse_next(input)?;
Ok((key, value))
}
/// Parse a Gemma4 bare key.
fn gemma4_key(input: &mut &str) -> ModalResult<String> {
let key = take_till(1.., |char: char| char == ':').parse_next(input)?.trim();
if key.is_empty() {
return Err(ErrMode::Cut(ContextError::new()));
}
Ok(key.to_string())
}
/// Parse a Gemma4 value.
fn gemma4_value(input: &mut &str) -> ModalResult<Value> {
alt((
gemma4_string.map(|value: &str| Value::String(value.to_string())),
gemma4_object.map(Value::Object),
gemma4_array_value.map(Value::Array),
gemma4_bare_value,
))
.parse_next(input)
}
/// Parse a Gemma4 string delimited by `<|"|>`.
fn gemma4_string<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
delimited(
literal(STRING_DELIM),
take_until(0.., STRING_DELIM),
literal(STRING_DELIM),
)
.parse_next(input)
}
/// Parse a nested Gemma4 object.
fn gemma4_object(input: &mut &str) -> ModalResult<Map<String, Value>> {
delimited(literal("{"), gemma4_args, literal("}")).parse_next(input)
}
/// Parse a Gemma4 array value.
fn gemma4_array_value(input: &mut &str) -> ModalResult<Vec<Value>> {
delimited(literal("["), gemma4_array_content, literal("]")).parse_next(input)
}
/// Parse Gemma4 array content.
fn gemma4_array_content(input: &mut &str) -> ModalResult<Vec<Value>> {
delimited(
ws0,
terminated(
separated(0.., gemma4_value, comma_separator),
opt(comma_separator),
),
ws0,
)
.parse_next(input)
}
/// Parse a Gemma4 bare scalar.
fn gemma4_bare_value(input: &mut &str) -> ModalResult<Value> {
take_till(1.., |char: char| matches!(char, ',' | '}' | ']'))
.map(parse_gemma4_scalar)
.parse_next(input)
}
/// Parse a Gemma4 comma separator.
fn comma_separator(input: &mut &str) -> ModalResult<()> {
delimited(ws0, literal(","), ws0).void().parse_next(input)
}
fn parse_gemma4_scalar(value: &str) -> Value {
let value = value.trim();
if value.is_empty() {
return Value::String(String::new());
}
if value == "true" {
return Value::Bool(true);
}
if value == "false" {
return Value::Bool(false);
}
if matches!(value, "null" | "none" | "nil" | "NULL" | "None" | "NIL") {
return Value::Null;
}
if value.contains('.') {
if let Ok(parsed) = value.parse::<f64>()
&& let Some(number) = Number::from_f64(parsed)
{
return Value::Number(number);
}
} else if let Ok(parsed) = value.parse::<i64>() {
return Value::Number(Number::from(parsed));
}
Value::String(value.to_string())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use serde_json::{Value, json};
use thiserror_ext::AsReport;
use vllm_tokenizer::test_utils::TestTokenizer;
use winnow::combinator::{eof, terminated};
use winnow::error::ErrMode;
use winnow::prelude::*;
use super::{
CHANNEL_END, CHANNEL_START, Gemma4ConfigDrivenParser, gemma4_array_content,
parse_gemma4_args,
};
use crate::tool::{Tool, ToolCallDelta};
use crate::unified::{
Result, UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput,
parsing_failed,
};
const CHANNEL_START_ID: u32 = 256;
const CHANNEL_END_ID: u32 = 257;
const TURN_BOUNDARY_ID: u32 = 258;
fn tokenizer() -> TestTokenizer {
TestTokenizer::new()
.with_special_token(CHANNEL_START, CHANNEL_START_ID)
.with_special_token(CHANNEL_END, CHANNEL_END_ID)
.with_special_token("<turn-boundary>", TURN_BOUNDARY_ID)
}
trait UnifiedParserTestExt {
fn parse_chunk(&mut self, chunk: &str) -> Result<UnifiedParserOutput>;
fn parse_complete(&mut self, text: &str) -> Result<UnifiedParserOutput>;
}
impl UnifiedParserTestExt for Gemma4ConfigDrivenParser {
fn parse_chunk(&mut self, chunk: &str) -> Result<UnifiedParserOutput> {
let mut output = UnifiedParserOutput::default();
self.parse_into(chunk, &mut output)?;
Ok(output)
}
fn parse_complete(&mut self, text: &str) -> Result<UnifiedParserOutput> {
let mut output = self.parse_chunk(text)?;
output.append(self.finish()?);
Ok(output)
}
}
trait UnifiedOutputTestExt {
fn normal_text(&self) -> String;
fn reasoning_text(&self) -> String;
fn calls(&self) -> Vec<&ToolCallDelta>;
fn coalesce(self) -> Self;
}
impl UnifiedOutputTestExt for UnifiedParserOutput {
fn normal_text(&self) -> String {
self.events
.iter()
.filter_map(|event| match event {
UnifiedParserEvent::Text(text) => Some(text.as_str()),
UnifiedParserEvent::Reasoning(_) | UnifiedParserEvent::ToolCall(_) => None,
})
.collect()
}
fn reasoning_text(&self) -> String {
self.events
.iter()
.filter_map(|event| match event {
UnifiedParserEvent::Reasoning(text) => Some(text.as_str()),
UnifiedParserEvent::Text(_) | UnifiedParserEvent::ToolCall(_) => None,
})
.collect()
}
fn calls(&self) -> Vec<&ToolCallDelta> {
self.events
.iter()
.filter_map(|event| match event {
UnifiedParserEvent::Text(_) | UnifiedParserEvent::Reasoning(_) => None,
UnifiedParserEvent::ToolCall(call) => Some(call),
})
.collect()
}
fn coalesce(self) -> Self {
self
}
}
fn parse_gemma4_array(array: &str) -> Result<Vec<Value>> {
let mut input = array;
match terminated(gemma4_array_content, eof).parse_next(&mut input) {
Ok(value) => Ok(value),
Err(ErrMode::Incomplete(_)) => Err(parsing_failed!("incomplete Gemma4 array")),
Err(ErrMode::Backtrack(error) | ErrMode::Cut(error)) => {
Err(parsing_failed!("{}", error))
}
}
}
fn test_tools() -> Vec<Tool> {
vec![
Tool {
name: "get_weather".to_string(),
description: None,
parameters: json!({ "type": "object" }),
strict: None,
},
Tool {
name: "get_time".to_string(),
description: None,
parameters: json!({ "type": "object" }),
strict: None,
},
Tool {
name: "write_file".to_string(),
description: None,
parameters: json!({ "type": "object" }),
strict: None,
},
Tool {
name: "Edit".to_string(),
description: None,
parameters: json!({ "type": "object" }),
strict: None,
},
Tool {
name: "search".to_string(),
description: None,
parameters: json!({ "type": "object" }),
strict: None,
},
Tool {
name: "set".to_string(),
description: None,
parameters: json!({ "type": "object" }),
strict: None,
},
Tool {
name: "get_status".to_string(),
description: None,
parameters: json!({ "type": "object" }),
strict: None,
},
Tool {
name: "todowrite".to_string(),
description: None,
parameters: json!({ "type": "object" }),
strict: None,
},
]
}
fn test_parser() -> Gemma4ConfigDrivenParser {
Gemma4ConfigDrivenParser::new(&test_tools(), Arc::new(tokenizer())).unwrap()
}
#[test]
fn gemma4_create_requires_channel_start_token() {
let error =
match Gemma4ConfigDrivenParser::new(&test_tools(), Arc::new(TestTokenizer::new())) {
Ok(_) => panic!("expected missing token error"),
Err(error) => error,
};
assert!(matches!(
error,
UnifiedParserError::MissingToken { token } if token == CHANNEL_START
));
}
fn collect_stream(chunks: &[&str]) -> UnifiedParserOutput {
let mut parser = test_parser();
let mut output = UnifiedParserOutput::default();
for chunk in chunks {
output.append(parser.parse_chunk(chunk).unwrap());
}
output.append(parser.finish().unwrap());
output.coalesce()
}
fn first_call(output: &UnifiedParserOutput) -> ToolCallDelta {
(*output.calls().first().expect("expected one tool call")).clone()
}
#[test]
fn gemma4_parse_args_handles_scalars_and_nested_values() {
let parsed = parse_gemma4_args(
"name:<|\"|>test<|\"|>,count:42,active:true,score:114.514,nested:{inner:<|\"|>value<|\"|>},items:[<|\"|>a<|\"|>,<|\"|>b<|\"|>]",
)
.unwrap();
assert_eq!(
Value::Object(parsed),
json!({
"name": "test",
"count": 42,
"active": true,
"score": 114.514,
"nested": { "inner": "value" },
"items": ["a", "b"],
})
);
}
#[test]
fn gemma4_parse_args_handles_empty_arguments() {
let parsed = parse_gemma4_args("").unwrap();
assert_eq!(Value::Object(parsed), json!({}));
}
#[test]
fn gemma4_parse_array_handles_bare_values() {
let parsed = parse_gemma4_array("42,true,114.514").unwrap();
assert_eq!(Value::Array(parsed), json!([42, true, 114.514]));
}
#[test]
fn gemma4_parse_complete_extracts_single_tool_call() {
let mut parser = test_parser();
let output = parser
.parse_complete("<|tool_call>call:get_weather{location:<|\"|>London<|\"|>}<tool_call|>")
.unwrap();
assert!(output.normal_text().is_empty());
assert_eq!(output.calls().len(), 1);
assert_eq!(first_call(&output).name.as_deref(), Some("get_weather"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "location": "London" })
);
}
#[test]
fn gemma4_parse_complete_rejects_incomplete_tool_call() {
let mut parser = test_parser();
let error = parser
.parse_complete("<|tool_call>call:get_weather{location:<|\"|>London")
.unwrap_err();
assert!(error.to_report_string().contains("incomplete Gemma4 tool call"));
}
#[test]
fn gemma4_streaming_basic_single_tool_call() {
let output = collect_stream(&[
"<|tool_call>",
"call:get_weather{",
"location:<|\"|>Paris",
", France",
"<|\"|>}",
"<tool_call|>",
]);
assert!(output.normal_text().is_empty());
assert_eq!(first_call(&output).name.as_deref(), Some("get_weather"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "location": "Paris, France" })
);
}
#[test]
fn gemma4_streaming_text_before_and_after_tool_call() {
let output = collect_stream(&[
"Let me check ",
"the weather. ",
"<|tool_call>",
"call:get_weather{",
"location:<|\"|>London<|\"|>}",
"<tool_call|><",
"div>",
]);
assert_eq!(output.normal_text(), "Let me check the weather. <div>");
assert_eq!(first_call(&output).name.as_deref(), Some("get_weather"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "location": "London" })
);
}
#[test]
fn gemma4_streaming_waits_for_complete_tool_call() {
let mut parser = test_parser();
let mut output = UnifiedParserOutput::default();
for chunk in [
"<|tool_call>",
"call:get_weather{",
"location:<|\"|>Paris<|\"|>}",
] {
output.append(parser.parse_chunk(chunk).unwrap());
assert!(output.calls().is_empty());
}
output.append(parser.parse_chunk("<tool_call|>").unwrap());
let output = output.coalesce();
assert_eq!(first_call(&output).name.as_deref(), Some("get_weather"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "location": "Paris" })
);
}
#[test]
fn gemma4_streaming_handles_boolean_split_across_chunks() {
let output = collect_stream(&[
"<|tool_call>",
"call:search{input:{all:tru",
"e}}",
"<tool_call|>",
]);
assert_eq!(first_call(&output).name.as_deref(), Some("search"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "input": { "all": true } })
);
}
#[test]
fn gemma4_streaming_handles_false_split_across_chunks() {
let output = collect_stream(&["<|tool_call>", "call:set{flag:fals", "e}", "<tool_call|>"]);
assert_eq!(first_call(&output).name.as_deref(), Some("set"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "flag": false })
);
}
#[test]
fn gemma4_streaming_handles_number_split_across_chunks() {
let output = collect_stream(&["<|tool_call>", "call:set{count:4", "2}", "<tool_call|>"]);
assert_eq!(first_call(&output).name.as_deref(), Some("set"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "count": 42 })
);
}
#[test]
fn gemma4_streaming_handles_split_string_delimiter() {
let output = collect_stream(&[
"<|tool_call>",
"call:todowrite{",
"content:<|\"|>Buy milk<|",
"\"|>}",
"<tool_call|>",
]);
assert_eq!(first_call(&output).name.as_deref(), Some("todowrite"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "content": "Buy milk" })
);
assert!(!first_call(&output).arguments.contains("<|"));
}
#[test]
fn gemma4_streaming_handles_split_tool_call_end_marker() {
let output = collect_stream(&[
"<|tool_call>",
"call:get_weather{location:<|\"|>Paris<|\"|>}<tool",
"_call|>",
]);
assert_eq!(first_call(&output).name.as_deref(), Some("get_weather"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "location": "Paris" })
);
}
#[test]
fn gemma4_streaming_handles_end_marker_literal_inside_string() {
let output = collect_stream(&[
"<|tool_call>",
"call:todowrite{",
"content:<|\"|>literal }<tool_call|> inside",
"<|\"|>}",
"<tool_call|>",
]);
assert_eq!(first_call(&output).name.as_deref(), Some("todowrite"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "content": "literal }<tool_call|> inside" })
);
}
#[test]
fn gemma4_streaming_handles_html_argument_without_duplication() {
let output = collect_stream(&[
"<|tool_call>",
"call:write_file{",
"path:<|\"|>index.html<|\"|>,",
"content:<|\"|><!DOCTYPE html>\n<",
"html lang=\"zh-CN\">\n<",
"head>\n <",
"meta charset=\"UTF-8\">\n <",
"meta name=\"viewport\" content=\"width=device-width\">\n",
"<|\"|>}",
"<tool_call|>",
]);
assert_eq!(first_call(&output).name.as_deref(), Some("write_file"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({
"path": "index.html",
"content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width\">\n",
})
);
}
#[test]
fn gemma4_streaming_trailing_bare_bool_is_not_duplicated() {
let output = collect_stream(&[
"<|tool_call>",
"call:Edit{",
"file_path:<|\"|>src/env.py<|\"|>,",
"old_string:<|\"|>old_val<|\"|>,",
"new_string:<|\"|>new_val<|\"|>,",
"replace_all:",
"false}",
"<tool_call|>",
]);
assert_eq!(first_call(&output).name.as_deref(), Some("Edit"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({
"file_path": "src/env.py",
"old_string": "old_val",
"new_string": "new_val",
"replace_all": false,
})
);
assert_eq!(
first_call(&output).arguments.matches("replace_all").count(),
1
);
}
#[test]
fn gemma4_finish_flushes_partial_start_marker_as_text() {
let mut parser = test_parser();
let mut output = parser.parse_chunk("<").unwrap();
output.append(parser.finish().unwrap());
assert_eq!(output.normal_text(), "<");
assert!(output.calls().is_empty());
}
#[test]
fn gemma4_streaming_emits_reasoning_then_text() {
let output = collect_stream(&["<|channel>thought\nreason<channel|>answer"]);
assert_eq!(output.reasoning_text(), "reason");
assert_eq!(output.normal_text(), "answer");
assert!(output.calls().is_empty());
}
#[test]
fn gemma4_streaming_holds_split_reasoning_start() {
let mut parser = test_parser();
let first = parser.parse_chunk("<|channel>").unwrap();
assert!(first.events.is_empty());
let mut output = parser.parse_chunk("thought\nrea").unwrap();
output.append(parser.parse_chunk("son<channel|>answer").unwrap());
output.append(parser.finish().unwrap());
assert_eq!(output.reasoning_text(), "reason");
assert_eq!(output.normal_text(), "answer");
}
#[test]
fn gemma4_initialize_open_channel_prompt_starts_in_reasoning() {
let mut parser = test_parser();
parser.initialize(&[CHANNEL_START_ID, 3000, 3001]).unwrap();
let output = parser.parse_complete("reason<channel|>answer").unwrap();
assert_eq!(output.reasoning_text(), "reason");
assert_eq!(output.normal_text(), "answer");
}
#[test]
fn gemma4_initialize_turn_prompt_starts_in_text() {
let mut parser = test_parser();
parser.initialize(&[TURN_BOUNDARY_ID, 3000, 3001]).unwrap();
let output = parser.parse_complete("<|channel>thought\nreason<channel|>answer").unwrap();
assert_eq!(output.reasoning_text(), "reason");
assert_eq!(output.normal_text(), "answer");
}
#[test]
fn gemma4_initialize_special_token_caps_boundary_scan() {
let mut parser = test_parser();
parser.initialize(&[CHANNEL_START_ID, 3000, TURN_BOUNDARY_ID, 3001]).unwrap();
let output = parser.parse_complete("answer").unwrap();
assert!(output.reasoning_text().is_empty());
assert_eq!(output.normal_text(), "answer");
}
#[test]
fn gemma4_initialize_closed_channel_prompt_starts_in_text() {
let mut parser = test_parser();
parser.initialize(&[CHANNEL_START_ID, 3000, 3001, CHANNEL_END_ID]).unwrap();
let output = parser.parse_complete("answer").unwrap();
assert!(output.reasoning_text().is_empty());
assert_eq!(output.normal_text(), "answer");
}
#[test]
fn gemma4_reasoning_tool_call_implicitly_ends_reasoning() {
let output = collect_stream(&[
"<|channel>thought\nNeed weather.",
"<|tool_call>",
"call:get_weather{location:<|\"|>Paris<|\"|>}",
"<tool_call|>",
]);
assert_eq!(output.reasoning_text(), "Need weather.");
assert!(output.normal_text().is_empty());
assert_eq!(first_call(&output).name.as_deref(), Some("get_weather"));
assert_eq!(
serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
json!({ "location": "Paris" })
);
}
#[test]
fn gemma4_bare_channel_start_is_plain_text() {
let output = collect_stream(&["<|channel>plain"]);
assert_eq!(output.normal_text(), "<|channel>plain");
assert!(output.reasoning_text().is_empty());
assert!(output.calls().is_empty());
}
#[test]
fn gemma4_finish_rejects_complete_args_without_end_marker() {
let mut parser = test_parser();
for chunk in ["<|tool_call>", "call:get_status{}"] {
parser.parse_chunk(chunk).unwrap();
}
let error = parser.finish().unwrap_err();
assert!(error.to_report_string().contains("incomplete Gemma4 tool call"));
}
#[test]
fn gemma4_reset_preserves_internally_buffered_arguments() {
let mut parser = test_parser();
for chunk in [
"<|tool_call>",
"call:write_file{",
"content:<|\"|>hello ",
"world<|\"|>",
] {
parser.parse_chunk(chunk).unwrap();
}
let raw = parser.reset();
assert_eq!(
raw,
"<|tool_call>call:write_file{content:<|\"|>hello world<|\"|>"
);
}
#[test]
fn gemma4_reset_preserves_completed_arguments_after_parse_error() {
let mut parser = test_parser();
let input = "<|tool_call>call:set{broken}<tool_call|>";
let _error = parser.parse_chunk(input).unwrap_err();
let raw = parser.reset();
assert_eq!(raw, input);
}
}
@@ -0,0 +1,804 @@
//! Config-driven unified parser: a shared boundary state machine parameterized
//! by a per-model [`ParserFormat`] implemented on zero-sized marker types.
//!
//! The engine owns a fixed five-state boundary FSM (plus a terminal `Done`
//! state) and all streaming mechanics: buffering, partial-marker holdback,
//! event application, raw-text reconstruction for [`UnifiedParser::reset`],
//! and end-of-stream semantics. A format contributes only:
//!
//! - boundary marker literals (with `Option` encoding which constructs exist),
//! - a winnow grammar for the tool-call header and, for custom argument
//! syntaxes, the complete argument body,
//! - a resumable scanner that delimits the raw argument body and chooses its
//! channel ([`ArgsStep`]): streamed verbatim for JSON-native bodies, or
//! buffered for one-shot conversion.
//!
//! Formats never define states or transitions. A format whose behavior does
//! not fit the fixed graph plus the small policy surface below should be
//! written as a standalone parser instead of growing this engine.
//!
//! Because every [`ParserFormat`] item is an associated constant or a static
//! method, a model parser is a zero-sized marker type plus a type alias in its
//! own format module (see [`gemma4`]):
//!
//! ```ignore
//! pub struct Gemma4Format;
//! impl ParserFormat for Gemma4Format { /* consts + two grammar fns */ }
//! pub type Gemma4ConfigDrivenParser = ConfigDrivenParser<Gemma4Format>;
//! ```
//!
//! Monomorphization then compiles `ConfigDrivenParser<F>` down to the
//! equivalent of a hand-written per-model parser: literals are inlined and
//! there is no dynamic dispatch on the format.
mod gemma4;
use std::marker::PhantomData;
pub use gemma4::{Gemma4ConfigDrivenParser, Gemma4Format};
use serde_json::{Map, Value};
use vllm_tokenizer::DynTokenizer;
use winnow::error::{ContextError, ErrMode, ModalResult};
use winnow::prelude::*;
use winnow::stream::{Partial, Stream};
use winnow::token::{literal, rest};
use super::{Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput};
use crate::reasoning::last_reasoning_boundary;
use crate::tool::{Tool, ToolCallDelta, ToolSchema, ToolSchemas};
use crate::unified::parsing_failed;
use crate::utils::{
JsonObjectScanState, MarkerScanState, parse_buffered_event, safe_text_len_mul,
take_json_object, take_until_marker,
};
/// Partial streaming input over buffered decoded text.
pub type Input<'i> = Partial<&'i str>;
/// Per-model format description consumed by [`ConfigDrivenParser`].
///
/// All items are associated constants or static methods, so implementors are
/// zero-sized marker types and the engine monomorphizes per format.
pub trait ParserFormat: 'static {
/// Human-readable format name used in error messages.
const NAME: &'static str;
/// Reasoning block opener (e.g. `<|channel>thought\n`), or `None` when the
/// format has no reasoning markers. Free to span multiple tokens.
const REASONING_START: Option<&'static str>;
/// Reasoning block closer, or `None` when the format has no reasoning.
const REASONING_END: Option<&'static str>;
/// Start marker of one tool call. Consumed by the engine before
/// [`ParserFormat::tool_header`] runs.
const CALL_START: &'static str;
/// End marker of one tool call. Scan target of [`ParserFormat::ArgsScan`].
const CALL_END: &'static str;
/// Section markers around consecutive tool calls (e.g. Qwen3's
/// `<tool_call>`/`</tool_call>` around `<function=...>` blocks), or `None`
/// when each `CALL_START`..`CALL_END` stands alone.
const SECTION: Option<(&'static str, &'static str)> = None;
/// Swallow all output after the section closes, like the `IgnoredRest`
/// event in standalone parsers. Only meaningful with `SECTION`.
const SUPPRESS_TEXT_AFTER_SECTION: bool = false;
/// Token texts of the reasoning boundary markers, used only to derive the
/// initial parser state from prompt token IDs (never for stream matching).
const REASONING_BOUNDARY_TOKENS: Option<(&'static str, &'static str)> = None;
/// Whether decoded output must keep tokenizer special tokens.
const PRESERVE_SPECIAL_TOKENS: bool = true;
/// Whether to normalize the request tools' parameter JSON schemas at
/// creation for schema-aware argument conversion. When `false`, the
/// engine skips schema construction and `tool_args` resolves to the
/// empty schema (all raw values keep their string-level interpretation).
const USES_TOOL_SCHEMAS: bool = false;
/// Resumable scanner delimiting the raw argument body.
type ArgsScan: ArgsScan;
/// Parse the tool-call header after `CALL_START`: the tool name plus
/// everything up to the start of the raw argument body.
fn tool_header(input: &mut Input<'_>) -> ModalResult<String>;
/// Parse one complete buffered argument body (end marker already
/// stripped) into a JSON object. Never invoked for scanners that stream
/// ([`ArgsStep::Streamed`]).
///
/// `schema` is the parameter schema resolved for this call's tool name —
/// the empty schema unless `USES_TOOL_SCHEMAS` is enabled.
///
/// The default parses the body as a JSON object. Note this normalizes
/// the text on re-serialization; JSON-native formats that need byte
/// fidelity should use a streaming scanner such as [`JsonArgsScan`]
/// instead, which bypasses this method entirely.
fn tool_args(schema: &ToolSchema, body: &str) -> ModalResult<Map<String, Value>> {
let _ = schema;
serde_json::from_str(body).map_err(|_| ErrMode::Cut(ContextError::new()))
}
}
/// One step of argument scanning, choosing the call's argument channel.
///
/// - incremental `Streamed`: JSON-native argument bodies (e.g. Kimi K2);
/// - one final `Streamed` (`fragment` = whole body, `complete: true`):
/// validate-first formats that buffer, then pass through verbatim;
/// - `Buffered`: custom argument syntaxes converted by
/// [`ParserFormat::tool_args`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArgsStep<'i> {
/// Raw-JSON channel: the argument body is already wire-format JSON, so
/// `fragment` is emitted verbatim as an argument delta right away.
///
/// - `fragment` must be the consumed prefix of this step's input.
/// - `complete` ends the call; the scanner has then also consumed the end
/// marker.
Streamed { fragment: &'i str, complete: bool },
/// Converted channel: the complete raw body of a custom argument syntax,
/// handed to [`ParserFormat::tool_args`] exactly once. The scanner has
/// consumed through the end marker.
Buffered { body: &'i str },
}
/// Resumable scanner that delimits one call's raw argument body.
///
/// On incomplete input it must record a resume position so repeated scans
/// stay linear in the argument length. A scanner statically commits to one
/// [`ArgsStep`] channel; mixing them within a call is a protocol violation.
pub trait ArgsScan: Default + Send {
/// Advance the scan over buffered `input`, resuming from prior progress.
// TODO(bugen): can we simply retrieve the length from offset of `input`?
fn scan<'i>(&mut self, input: &mut Input<'i>, end: &str) -> ModalResult<ArgsStep<'i>>;
}
/// Default argument scanner: a plain resumable marker scan with no lexical
/// structure awareness, buffering the complete body.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PlainArgsScan(MarkerScanState);
impl ArgsScan for PlainArgsScan {
fn scan<'i>(&mut self, input: &mut Input<'i>, end: &str) -> ModalResult<ArgsStep<'i>> {
let body = take_until_marker(end, &mut self.0).parse_next(input)?;
input.next_slice(end.len());
Ok(ArgsStep::Buffered { body })
}
}
/// Streaming scanner for JSON-native argument bodies: emits raw fragments as
/// the top-level JSON object is scanned (lexically, string-aware), then
/// consumes the end marker once the object closes.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct JsonArgsScan(JsonObjectScanState);
impl ArgsScan for JsonArgsScan {
fn scan<'i>(&mut self, input: &mut Input<'i>, end: &str) -> ModalResult<ArgsStep<'i>> {
if self.0.complete() {
let _matched: &str = literal(end).parse_next(input)?;
return Ok(ArgsStep::Streamed {
fragment: "",
complete: true,
});
}
let text = **input;
let len = take_json_object(input, &mut self.0)?;
Ok(ArgsStep::Streamed {
fragment: &text[..len],
complete: false,
})
}
}
/// Boundary marker recognized by the fixed transition graph.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Boundary {
ReasoningStart,
ReasoningEnd,
SectionStart,
SectionEnd,
CallStart,
}
/// One parsed engine event over buffered streaming input.
#[derive(Debug, Clone, PartialEq)]
enum Event {
/// A safe run of visible text (value is the consumed buffer prefix).
Text,
/// A safe run of reasoning text (value is the consumed buffer prefix).
Reasoning,
/// Consumed input that produces no output (between-call noise, suppressed
/// trailing text).
Ignored,
/// A boundary marker driving a fixed state transition.
Boundary(Boundary),
/// A complete tool-call header.
CallHeader { name: String },
/// A raw streamed argument fragment (value is the leading `len` bytes of
/// the consumed buffer prefix); `complete` ends the call.
ArgsFragment { len: usize, complete: bool },
/// A complete buffered tool call with converted arguments.
CallComplete { args: Map<String, Value> },
}
/// Engine parsing state. The state set and transition graph are fixed;
/// `Option`-typed format constants decide which edges are reachable.
enum Mode<F: ParserFormat> {
/// Visible assistant text.
Text,
/// Inside a reasoning block.
Reasoning,
/// Inside the section, between tool calls. Unreachable without `SECTION`.
ToolBetween,
/// After `CALL_START`, parsing the tool-call header.
ToolHeader,
/// Inside the raw argument body, scanning for `CALL_END`.
ToolArgs {
/// Tool name from the header; taken by the first emitted delta.
name: Option<String>,
/// Raw header text consumed by `tool_header`, kept for `reset()`.
raw_header: String,
scan: F::ArgsScan,
},
/// Section closed with `SUPPRESS_TEXT_AFTER_SECTION`: swallow the rest.
Done,
}
/// Boundary marker candidates watched in one engine mode, precomputed from
/// the format constants so per-event parsing builds nothing.
struct ModeMarkers {
/// Marker texts tried in order; also the safe-content fallback scan set.
texts: Vec<&'static str>,
/// Transition triggered by each marker, index-aligned with `texts`.
boundaries: Vec<Boundary>,
}
impl ModeMarkers {
fn new(candidates: impl IntoIterator<Item = (&'static str, Boundary)>) -> Self {
let (texts, boundaries) = candidates.into_iter().unzip();
Self { texts, boundaries }
}
}
/// Per-mode marker candidates derived once from a [`ParserFormat`].
struct ModeMarkerSet {
text: ModeMarkers,
reasoning: ModeMarkers,
between: ModeMarkers,
}
impl ModeMarkerSet {
fn of<F: ParserFormat>() -> Self {
// The tool-entry marker watched in text and reasoning modes: the
// section opener when the format has one, the call start otherwise.
let tool_entry = match F::SECTION {
Some((section_start, _)) => (section_start, Boundary::SectionStart),
None => (F::CALL_START, Boundary::CallStart),
};
let reasoning_start = F::REASONING_START.map(|marker| (marker, Boundary::ReasoningStart));
// Tool entry inside reasoning implicitly ends it: the transition to a
// tool state simply stops routing text to the reasoning channel.
let reasoning_end = F::REASONING_END.map(|marker| (marker, Boundary::ReasoningEnd));
let between = F::SECTION.map(|(_, section_end)| {
[
(F::CALL_START, Boundary::CallStart),
(section_end, Boundary::SectionEnd),
]
});
Self {
text: ModeMarkers::new(reasoning_start.into_iter().chain([tool_entry])),
reasoning: ModeMarkers::new(reasoning_end.into_iter().chain([tool_entry])),
between: ModeMarkers::new(between.into_iter().flatten()),
}
}
}
/// Unified parser driven by a [`ParserFormat`] marker type.
///
/// Owns all streaming state; the format contributes constants and grammar.
pub struct ConfigDrivenParser<F: ParserFormat> {
buffer: String,
mode: Mode<F>,
markers: ModeMarkerSet,
tool_schemas: ToolSchemas,
emitted_tool_count: usize,
tokenizer: DynTokenizer,
/// Resolved reasoning boundary token IDs for prompt-state derivation.
boundary_ids: Option<(u32, u32)>,
_format: PhantomData<fn() -> F>,
}
impl<F: ParserFormat> ConfigDrivenParser<F> {
/// Create a parser for one request stream.
pub fn new(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
let tool_schemas = if F::USES_TOOL_SCHEMAS {
ToolSchemas::from_tools(tools)
} else {
ToolSchemas::default()
};
let boundary_ids = match F::REASONING_BOUNDARY_TOKENS {
Some((start, end)) => {
let start_id = tokenizer.token_to_id(start).ok_or_else(|| {
UnifiedParserError::MissingToken {
token: start.to_string(),
}
})?;
let end_id =
tokenizer.token_to_id(end).ok_or_else(|| UnifiedParserError::MissingToken {
token: end.to_string(),
})?;
Some((start_id, end_id))
}
None => None,
};
Ok(Self {
buffer: String::new(),
mode: Mode::Text,
markers: ModeMarkerSet::of::<F>(),
tool_schemas,
emitted_tool_count: 0,
tokenizer,
boundary_ids,
_format: PhantomData,
})
}
fn apply_event(
&mut self,
event: Event,
consumed_len: usize,
output: &mut UnifiedParserOutput,
) -> Result<()> {
match event {
Event::Text => output.push_text(self.buffer[..consumed_len].to_string()),
Event::Reasoning => output.push_reasoning(self.buffer[..consumed_len].to_string()),
Event::Ignored => {}
Event::Boundary(boundary) => match boundary {
Boundary::ReasoningStart => self.mode = Mode::Reasoning,
Boundary::ReasoningEnd => self.mode = Mode::Text,
Boundary::SectionStart => self.mode = Mode::ToolBetween,
Boundary::SectionEnd => {
self.mode = if F::SUPPRESS_TEXT_AFTER_SECTION {
Mode::Done
} else {
Mode::Text
};
}
Boundary::CallStart => self.mode = Mode::ToolHeader,
},
Event::CallHeader { name } => {
self.mode = Mode::ToolArgs {
name: Some(name),
raw_header: self.buffer[..consumed_len].to_string(),
scan: F::ArgsScan::default(),
};
}
Event::ArgsFragment { len, complete } => {
let Mode::ToolArgs { name, .. } = &mut self.mode else {
return Err(parsing_failed!(
"{} argument fragment without an active tool call",
F::NAME
));
};
let name = name.take();
if name.is_some() || len > 0 {
output.push_call(ToolCallDelta {
tool_index: self.emitted_tool_count,
name,
arguments: self.buffer[..len].to_string(),
});
}
if complete {
self.emitted_tool_count += 1;
self.mode = if F::SECTION.is_some() {
Mode::ToolBetween
} else {
Mode::Text
};
}
}
Event::CallComplete { args } => {
let mode = std::mem::replace(&mut self.mode, Mode::Text);
let Mode::ToolArgs {
name: Some(name), ..
} = mode
else {
return Err(parsing_failed!(
"{} buffered arguments without an active tool call",
F::NAME
));
};
let arguments = serde_json::to_string(&args)
.map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?;
output.push_call(ToolCallDelta {
tool_index: self.emitted_tool_count,
name: Some(name),
arguments,
});
self.emitted_tool_count += 1;
self.mode = if F::SECTION.is_some() {
Mode::ToolBetween
} else {
Mode::Text
};
}
}
Ok(())
}
fn initialize_mode(&mut self, prompt_token_ids: &[u32]) {
let in_reasoning = self.boundary_ids.and_then(|(start_id, end_id)| {
last_reasoning_boundary(prompt_token_ids, start_id, end_id, self.tokenizer.as_ref())
});
self.mode = match in_reasoning {
Some(true) => Mode::Reasoning,
Some(false) | None => Mode::Text,
};
}
fn reset(&mut self) -> String {
let raw = match std::mem::replace(&mut self.mode, Mode::Text) {
Mode::Text | Mode::Done => std::mem::take(&mut self.buffer),
Mode::Reasoning => {
// Reasoning mode is only reachable through this marker (or a
// prompt already inside reasoning, where there is no marker
// to restore; the opener default keeps reset lossless for the
// common in-stream case).
let start = F::REASONING_START.unwrap_or("");
format!("{}{}", start, std::mem::take(&mut self.buffer))
}
// The section opener was already consumed alongside previously
// emitted calls, so only the unconsumed buffer remains.
Mode::ToolBetween => std::mem::take(&mut self.buffer),
Mode::ToolHeader => {
format!("{}{}", F::CALL_START, std::mem::take(&mut self.buffer))
}
Mode::ToolArgs { raw_header, .. } => {
format!(
"{}{}{}",
F::CALL_START,
raw_header,
std::mem::take(&mut self.buffer)
)
}
};
self.mode = Mode::Text;
self.emitted_tool_count = 0;
raw
}
}
impl<F: ParserFormat> UnifiedParser for ConfigDrivenParser<F> {
fn create(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Box<dyn UnifiedParser>>
where
Self: Sized + 'static,
{
Self::new(tools, tokenizer).map(|parser| Box::new(parser) as Box<dyn UnifiedParser>)
}
fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
self.buffer.clear();
self.emitted_tool_count = 0;
self.initialize_mode(prompt_token_ids);
Ok(())
}
fn preserve_special_tokens(&self) -> bool {
F::PRESERVE_SPECIAL_TOKENS
}
fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> {
self.buffer.push_str(chunk);
while let Some((event, consumed_len)) = {
parse_buffered_event(&self.buffer, |input| {
parse_next_event::<F>(input, &mut self.mode, &self.markers, &self.tool_schemas)
})?
} {
self.apply_event(event, consumed_len, output)?;
self.buffer.drain(..consumed_len);
}
Ok(())
}
fn finish(&mut self) -> Result<UnifiedParserOutput> {
let mut output = UnifiedParserOutput::default();
match &self.mode {
Mode::Text => output.push_text(std::mem::take(&mut self.buffer)),
Mode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)),
// All calls were emitted; an unclosed section at end of stream is
// tolerated and any buffered between-call noise is dropped.
Mode::ToolBetween | Mode::Done => {}
Mode::ToolHeader | Mode::ToolArgs { .. } => {
return Err(parsing_failed!("incomplete {} tool call", F::NAME));
}
}
let _ = self.reset();
Ok(output)
}
fn reset(&mut self) -> String {
ConfigDrivenParser::reset(self)
}
}
/// Parse one engine event from buffered streaming input.
///
/// In `ToolBetween`, non-marker text is separator noise (typically
/// whitespace) and is consumed without output.
fn parse_next_event<F: ParserFormat>(
input: &mut Input<'_>,
mode: &mut Mode<F>,
markers: &ModeMarkerSet,
schemas: &ToolSchemas,
) -> ModalResult<Event> {
match mode {
Mode::Text => boundary_or_content(input, &markers.text, Event::Text),
Mode::Reasoning => boundary_or_content(input, &markers.reasoning, Event::Reasoning),
Mode::ToolBetween => boundary_or_content(input, &markers.between, Event::Ignored),
Mode::ToolHeader => F::tool_header(input).map(|name| Event::CallHeader { name }),
Mode::ToolArgs { name, scan, .. } => {
// A streamed call takes `name` with its first fragment; the schema
// is only consulted on the buffered path, where `name` is intact.
let schema = schemas.resolve(name.as_deref().unwrap_or(""));
args_event::<F>(input, scan, schema)
}
Mode::Done => rest.value(Event::Ignored).parse_next(input),
}
}
/// Parse the next argument event: a raw streamed fragment, or the complete
/// buffered body converted through [`ParserFormat::tool_args`].
fn args_event<F: ParserFormat>(
input: &mut Input<'_>,
scan: &mut F::ArgsScan,
schema: &ToolSchema,
) -> ModalResult<Event> {
match scan.scan(input, F::CALL_END)? {
ArgsStep::Streamed { fragment, complete } => Ok(Event::ArgsFragment {
len: fragment.len(),
complete,
}),
ArgsStep::Buffered { body } => {
let args = F::tool_args(schema, body)?;
Ok(Event::CallComplete { args })
}
}
}
/// Match one of the mode's marker candidates at the cursor (in order), or
/// emit a safe content run held back before the earliest possible marker.
fn boundary_or_content(
input: &mut Input<'_>,
markers: &ModeMarkers,
content: Event,
) -> ModalResult<Event> {
// Engine invariant: every reachable mode watches at least one marker
// (`ToolBetween` without `SECTION` is unreachable).
debug_assert!(!markers.texts.is_empty());
for (marker, boundary) in markers.texts.iter().zip(&markers.boundaries) {
let matched: ModalResult<&str> = literal(*marker).parse_next(input);
match matched {
Ok(_) => return Ok(Event::Boundary(*boundary)),
Err(ErrMode::Incomplete(needed)) => return Err(ErrMode::Incomplete(needed)),
Err(_) => {}
}
}
safe_text_len_mul(input, &markers.texts).map(|_| content)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use vllm_tokenizer::test_utils::TestTokenizer;
use winnow::combinator::seq;
use winnow::error::ModalResult;
use winnow::prelude::*;
use winnow::token::{literal, take_until};
use super::{ConfigDrivenParser, Input, JsonArgsScan, ParserFormat};
use crate::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
/// Minimal JSON-native test format: `<jtool:NAME>{...}</jtool>`.
///
/// Uses the streaming [`JsonArgsScan`] and the default `tool_args`
/// (which is never invoked on the streamed channel).
struct TestJsonFormat;
impl ParserFormat for TestJsonFormat {
const NAME: &'static str = "TestJson";
const REASONING_START: Option<&'static str> = None;
const REASONING_END: Option<&'static str> = None;
const CALL_START: &'static str = "<jtool:";
const CALL_END: &'static str = "</jtool>";
type ArgsScan = JsonArgsScan;
fn tool_header(input: &mut Input<'_>) -> ModalResult<String> {
let (name,): (&str,) = seq!(take_until(1.., ">"), _: literal(">")).parse_next(input)?;
Ok(name.to_string())
}
}
type TestJsonParser = ConfigDrivenParser<TestJsonFormat>;
fn test_parser() -> TestJsonParser {
TestJsonParser::new(&[], Arc::new(TestTokenizer::new())).unwrap()
}
fn parse_stream(chunks: &[&str]) -> UnifiedParserOutput {
let mut parser = test_parser();
let mut output = UnifiedParserOutput::default();
for chunk in chunks {
let mut step = UnifiedParserOutput::default();
parser.parse_into(chunk, &mut step).unwrap();
output.append(step);
}
output.append(parser.finish().unwrap());
output
}
fn split_chunks(text: &str, size: usize) -> Vec<&str> {
let mut chunks = Vec::new();
let mut rest = text;
while !rest.is_empty() {
let mut end = size.min(rest.len());
while !rest.is_char_boundary(end) {
end += 1;
}
let (chunk, tail) = rest.split_at(end);
chunks.push(chunk);
rest = tail;
}
chunks
}
fn normal_text(output: &UnifiedParserOutput) -> String {
output
.events
.iter()
.filter_map(|event| match event {
UnifiedParserEvent::Text(text) => Some(text.as_str()),
_ => None,
})
.collect()
}
/// Merge streamed tool-call deltas by tool index.
fn merged_calls(output: &UnifiedParserOutput) -> Vec<(usize, Option<String>, String)> {
let mut calls: Vec<(usize, Option<String>, String)> = Vec::new();
for event in &output.events {
let UnifiedParserEvent::ToolCall(call) = event else {
continue;
};
match calls.iter_mut().find(|(index, ..)| *index == call.tool_index) {
Some((_, name, arguments)) => {
if name.is_none() {
*name = call.name.clone();
}
arguments.push_str(&call.arguments);
}
None => calls.push((call.tool_index, call.name.clone(), call.arguments.clone())),
}
}
calls
}
/// Fidelity-sensitive JSON: exponent and trailing-zero number forms plus
/// irregular whitespace, all of which a parse/re-serialize would destroy.
const FIDELITY_JSON: &str = "{\"b\": 1e2, \"a\": \"x\",\n \"n\": 0.10}";
#[test]
fn json_args_pass_through_verbatim() {
let input = format!("Check: <jtool:get_weather>{FIDELITY_JSON}</jtool> done");
let output = parse_stream(&[&input]);
assert_eq!(normal_text(&output), "Check: done");
let calls = merged_calls(&output);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1.as_deref(), Some("get_weather"));
assert_eq!(calls[0].2, FIDELITY_JSON);
}
#[test]
fn json_args_stream_incrementally_with_name_on_first_delta() {
let input = format!("<jtool:search>{FIDELITY_JSON}</jtool>");
let chunks = split_chunks(&input, 5);
let output = parse_stream(&chunks);
let deltas: Vec<_> = output
.events
.iter()
.filter_map(|event| match event {
UnifiedParserEvent::ToolCall(call) => Some(call),
_ => None,
})
.collect();
assert!(deltas.len() > 1, "expected streamed argument deltas");
assert_eq!(deltas[0].name.as_deref(), Some("search"));
assert!(deltas[1..].iter().all(|delta| delta.name.is_none()));
let calls = merged_calls(&output);
assert_eq!(calls[0].2, FIDELITY_JSON);
}
#[test]
fn json_args_emit_before_call_completes() {
let mut parser = test_parser();
let mut output = UnifiedParserOutput::default();
parser.parse_into("<jtool:search>{\"a\": \"lo", &mut output).unwrap();
let calls = merged_calls(&output);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1.as_deref(), Some("search"));
assert!(calls[0].2.starts_with("{\"a\""));
let mut rest = UnifiedParserOutput::default();
parser.parse_into("ng\"}</jtool>", &mut rest).unwrap();
output.append(rest);
output.append(parser.finish().unwrap());
assert_eq!(merged_calls(&output)[0].2, "{\"a\": \"long\"}");
}
#[test]
fn json_args_empty_object() {
let output = parse_stream(&["<jtool:noop>{}</jtool>"]);
let calls = merged_calls(&output);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1.as_deref(), Some("noop"));
assert_eq!(calls[0].2, "{}");
}
#[test]
fn json_args_end_marker_literal_inside_string() {
let json = "{\"s\": \"</jtool> inside\"}";
let input = format!("<jtool:echo>{json}</jtool>");
let output = parse_stream(&[&input]);
let calls = merged_calls(&output);
assert_eq!(calls[0].2, json);
assert!(normal_text(&output).is_empty());
}
#[test]
fn json_args_two_calls_use_distinct_indices() {
let output = parse_stream(&["<jtool:a>{}</jtool><jtool:b>{\"k\": 1}</jtool>"]);
let calls = merged_calls(&output);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0], (0, Some("a".to_string()), "{}".to_string()));
assert_eq!(
calls[1],
(1, Some("b".to_string()), "{\"k\": 1}".to_string())
);
}
#[test]
fn json_args_incomplete_errors_at_finish() {
let mut parser = test_parser();
let mut output = UnifiedParserOutput::default();
parser.parse_into("<jtool:x>{\"a\": 1", &mut output).unwrap();
let error = parser.finish().unwrap_err();
assert!(error.to_string().contains("incomplete TestJson tool call"));
}
#[test]
fn json_args_garbage_before_end_marker_errors() {
let mut parser = test_parser();
let mut output = UnifiedParserOutput::default();
let result = parser.parse_into("<jtool:x>{} junk</jtool>", &mut output);
assert!(result.is_err());
}
}
+5
View File
@@ -1,9 +1,14 @@
//! Unified parser interface for reasoning and tool-call deltas.
mod combined;
mod config_driven;
mod gemma4;
pub use combined::CombinedParser;
pub use config_driven::{
ArgsScan, ArgsStep, ConfigDrivenParser, Gemma4ConfigDrivenParser, Gemma4Format, JsonArgsScan,
ParserFormat, PlainArgsScan,
};
pub use gemma4::Gemma4UnifiedParser;
use thiserror::Error;
use thiserror_ext::Macro;