forked from Karylab-cklius/vllm
[Rust Frontend] Tolerate whitespace before the outer brace in JSON tool-call parsers (#48034)
Signed-off-by: Tahsin Tunan <tahsintunan@gmail.com>
This commit is contained in:
@@ -451,6 +451,57 @@ mod tests {
|
||||
output
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_stream_parses_formatted_tool_call_without_latch() {
|
||||
use vllm_parser::tool::{HermesToolParser, ToolParser as _};
|
||||
|
||||
let hermes = HermesToolParser::create(&[]).unwrap();
|
||||
let parser = vllm_parser::unified::CombinedParser::new(None, Some(hermes));
|
||||
|
||||
// Regression guard: a formatted call (space before the outer `}`) must not
|
||||
// trip the parse-error latch that turns it and every later call into text.
|
||||
let d1 = decoded_delta(
|
||||
r#"<tool_call>{"name":"get_weather","arguments":{"location":"Paris"} }</tool_call>"#,
|
||||
);
|
||||
let d2 = finished_delta(
|
||||
r#"<tool_call>{"name":"get_time","arguments":{"tz":"UTC"}}</tool_call>"#,
|
||||
);
|
||||
|
||||
let stream = stream::iter(vec![d1, d2].into_iter().map(Ok));
|
||||
let events = unified_event_stream(stream, Box::new(parser))
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<crate::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
|
||||
let names: Vec<&str> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
AssistantEvent::ToolCallStart { name, .. } => Some(name.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, ["get_weather", "get_time"]);
|
||||
|
||||
let text_leaks = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
matches!(
|
||||
event,
|
||||
AssistantEvent::TextDelta {
|
||||
kind: AssistantBlockKind::Text,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
text_leaks, 0,
|
||||
"formatted tool call leaked into text: {events:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_stream_emits_reasoning_only_deltas() {
|
||||
let events = collect(
|
||||
|
||||
@@ -238,7 +238,9 @@ fn parse_llama_arguments_event(
|
||||
|
||||
/// Parse the outer closing brace for one Llama JSON tool call.
|
||||
fn tool_call_close_event(input: &mut JsonToolInput<'_>) -> ModalResult<LlamaJsonEvent> {
|
||||
literal("}").value(LlamaJsonEvent::ToolCallClose).parse_next(input)
|
||||
seq!(_: ws0, _: literal("}"))
|
||||
.value(LlamaJsonEvent::ToolCallClose)
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a semicolon separator after one Llama JSON tool call.
|
||||
@@ -266,6 +268,28 @@ mod tests {
|
||||
format!(r#"{{"name":"{function_name}","parameters":{parameters}}}"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llama_tolerates_whitespace_before_outer_brace() {
|
||||
// Whitespace between the parameters object's `}` and the outer `}` must
|
||||
// still parse (json.loads / raw_decode parity).
|
||||
let mut whole = Llama3JsonToolParser::new(&test_tools());
|
||||
let whole_output = whole.parse_complete(r#"{"name":"f","parameters":{"x":1} }"#).unwrap();
|
||||
assert_eq!(whole_output.calls().len(), 1);
|
||||
assert_eq!(whole_output.calls()[0].name.as_deref(), Some("f"));
|
||||
assert_eq!(whole_output.calls()[0].arguments, r#"{"x":1}"#);
|
||||
|
||||
// Same input, whitespace before the outer `}` split across a chunk boundary.
|
||||
let mut chunked = Llama3JsonToolParser::new(&test_tools());
|
||||
let mut output = ToolParserOutput::default();
|
||||
for chunk in [r#"{"name":"f","parameters":{"x":1}"#, " ", "}"] {
|
||||
output.append(chunked.parse_chunk(chunk).unwrap());
|
||||
}
|
||||
output.append(chunked.finish().unwrap());
|
||||
let output = output.coalesce();
|
||||
assert_eq!(output.calls().len(), 1);
|
||||
assert_eq!(output.calls()[0].arguments, r#"{"x":1}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llama_json_parse_complete_without_tool_call_keeps_text() {
|
||||
let mut parser = Llama3JsonToolParser::new(&test_tools());
|
||||
|
||||
@@ -317,7 +317,7 @@ fn tool_call_close_event(
|
||||
input: &mut JsonToolInput<'_>,
|
||||
config: JsonToolCallConfig,
|
||||
) -> ModalResult<JsonToolCallEvent> {
|
||||
let _ = literal("}").parse_next(input)?;
|
||||
seq!(_: ws0, _: literal("}")).parse_next(input)?;
|
||||
|
||||
match config.delimiter {
|
||||
Some(delimiter) => alt((
|
||||
@@ -406,6 +406,34 @@ mod tests {
|
||||
output.coalesce()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_tool_call_tolerates_whitespace_before_outer_brace() {
|
||||
// Pretty-printed JSON puts whitespace between the arguments object's `}`
|
||||
// and the outer object's `}`; it must still parse (json.loads parity).
|
||||
let mut whole = JsonToolCallParser::new(DELIMITED_CONFIG);
|
||||
let whole_output = collect_chunks(
|
||||
&mut whole,
|
||||
&[r#"<tool_calls>{"function":"f","parameters":{"x":1} }</tool_calls>"#],
|
||||
);
|
||||
assert_eq!(whole_output.calls().len(), 1);
|
||||
assert_eq!(whole_output.calls()[0].name.as_deref(), Some("f"));
|
||||
assert_eq!(whole_output.calls()[0].arguments, r#"{"x":1}"#);
|
||||
|
||||
// Same input, but the whitespace before the outer `}` is split across a
|
||||
// chunk boundary (exercises `ws0` returning Incomplete on `Partial`).
|
||||
let mut chunked = JsonToolCallParser::new(DELIMITED_CONFIG);
|
||||
let chunked_output = collect_chunks(
|
||||
&mut chunked,
|
||||
&[
|
||||
r#"<tool_calls>{"function":"f","parameters":{"x":1}"#,
|
||||
" ",
|
||||
"}</tool_calls>",
|
||||
],
|
||||
);
|
||||
assert_eq!(chunked_output.calls().len(), 1);
|
||||
assert_eq!(chunked_output.calls()[0].arguments, r#"{"x":1}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_tool_call_delimiter_extracts_multiple_calls_in_one_block() {
|
||||
let input = build_tool_calls(&[
|
||||
|
||||
Reference in New Issue
Block a user