Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 89 additions & 38 deletions rust/src/chat/src/output/default/unified.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ impl UnifiedParserState {

match self.parser.finish() {
Ok(output) => self.process_parser_output(output, &mut events)?,
// The output stopped inside a tool call (`max_tokens`, a stop
// string): drop the unfinished markup like Python's parser engine
// instead of returning it as text. Earlier events stand.
Err(error) if error.is_incomplete_tool_call() => {
self.open_call_index = None;
let _ = self.parser.reset();
}
Err(error) => {
warn!(
error = %error.as_report(),
Expand Down Expand Up @@ -892,52 +899,96 @@ mod tests {
);
}

/// The output stopped inside a tool call (`max_tokens`, a stop string): the
/// unfinished markup is dropped and earlier text, if any, is kept.
#[tokio::test]
async fn unified_stream_recovers_incomplete_gemma4_tool_call_at_eos() {
let tokenizer = TestTokenizer::new()
.with_special_token("<|channel>", 256)
.with_special_token("<channel|>", 257);
async fn unified_stream_drops_incomplete_gemma4_tool_call_at_eos() {
let tools = vec![Tool {
name: "write_file".to_string(),
description: None,
parameters: serde_json::json!({ "type": "object" }),
strict: None,
defer_loading: None,
}];
let parser = Gemma4UnifiedParser::new(&tools, Arc::new(tokenizer)).unwrap();
let events = vec![
decoded_delta("<|tool_call>"),
decoded_delta("call:write_file{"),
decoded_delta("content:<|\"|>hello "),
finished_delta("world<|\"|>"),
];
let stream = stream::iter(events.into_iter().map(Ok));
let events = unified_event_stream(stream, Box::new(parser))
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<crate::Result<Vec<_>>>()
.unwrap();
for prefix in ["", "Writing it. "] {
let tokenizer = TestTokenizer::new()
.with_special_token("<|channel>", 256)
.with_special_token("<channel|>", 257);
let parser = Gemma4UnifiedParser::new(&tools, Arc::new(tokenizer)).unwrap();
let events = vec![
decoded_delta(prefix),
decoded_delta("<|tool_call>"),
decoded_delta("call:write_file{"),
decoded_delta("content:<|\"|>hello "),
finished_delta("world<|\"|>"),
];
let stream = stream::iter(events.into_iter().map(Ok));
let events = unified_event_stream(stream, Box::new(parser))
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<crate::Result<Vec<_>>>()
.unwrap();

let text = (!prefix.is_empty()).then(|| AssistantEvent::TextDelta {
kind: AssistantBlockKind::Text,
delta: prefix.to_string(),
token_count: None,
});
let done = AssistantEvent::Done {
usage: done_usage(0),
timestamps: Default::default(),
spec_decode_metrics: None,
finish_reason: crate::FinishReason::Stop(None),
kv_transfer_params: None,
ec_transfer_params: None,
weight_versions: None,
};
assert_eq!(
events,
text.into_iter().chain([done]).collect::<Vec<_>>(),
"{prefix:?}"
);
}
}

assert_eq!(
events,
vec![
AssistantEvent::TextDelta {
kind: AssistantBlockKind::Text,
delta: "<|tool_call>call:write_file{content:<|\"|>hello world<|\"|>"
.to_string(),
token_count: None,
},
AssistantEvent::Done {
usage: done_usage(0),
timestamps: Default::default(),
spec_decode_metrics: None,
finish_reason: crate::FinishReason::Stop(None),
kv_transfer_params: None,
ec_transfer_params: None,
weight_versions: None,
},
]
);
/// The same through a tool parser: an opener cut short drops whether the
/// output arrives in pieces or in one delta, and only the preceding text
/// remains.
#[tokio::test]
async fn unified_stream_drops_incomplete_tool_parser_call_at_eos() {
use vllm_parser::tool::{Qwen3CoderToolParser, ToolParser as _};

let chunks = ["Checking the weather. ", "<tool_call>", "\n", "<function"];
let whole = chunks.concat();
for deltas in [chunks.to_vec(), vec![whole.as_str()]] {
let qwen = Qwen3CoderToolParser::create(&[], Arc::new(TestTokenizer::new())).unwrap();
let parser = vllm_parser::unified::CombinedParser::new(None, Some(qwen));
let (last, rest) = deltas.split_last().unwrap();
let events: Vec<_> = rest
.iter()
.map(|delta| Ok(decoded_delta(delta)))
.chain([Ok(finished_delta(last))])
.collect();
let events = unified_event_stream(stream::iter(events), Box::new(parser))
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<crate::Result<Vec<_>>>()
.unwrap();

let text: String = events
.iter()
.filter_map(|event| match event {
AssistantEvent::TextDelta { delta, .. } => Some(delta.as_str()),
AssistantEvent::ToolCallStart { .. }
| AssistantEvent::ToolCallArgumentsDelta { .. } => {
panic!("unexpected tool call event: {event:?}")
}
_ => None,
})
.collect();
assert_eq!(text, "Checking the weather. ", "deltas: {deltas:?}");
}
}
}
6 changes: 4 additions & 2 deletions rust/src/parser/src/tool/deepseek_dsml/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::utils::{
Attributed, AttributedExt, Marker, MarkerScanState, SpecialToken,
parse_buffered_event_attributed, safe_text_len_mul, take_until_marker,
};
use super::{Result, ToolCallDelta, ToolParserOutput};
use super::{Result, ToolCallDelta, ToolParserError, ToolParserOutput};
use crate::tool::Tool;

mod deepseek_v32;
Expand Down Expand Up @@ -238,7 +238,9 @@ impl DeepSeekDsmlToolParser {
DsmlMode::Text => output.push_text(&self.buffer.text),
DsmlMode::Done => {}
DsmlMode::ToolBlock { .. } => {
return Err(parsing_failed!("incomplete DeepSeek DSML tool call"));
return Err(ToolParserError::IncompleteToolCall {
parser: "DeepSeek DSML",
});
}
}
let _ = self.reset();
Expand Down
3 changes: 1 addition & 2 deletions rust/src/parser/src/tool/deepseek_json/deepseek_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,7 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete DeepSeek V3 tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete DeepSeek V3 tool call"].assert_eq(&error.to_report_string());
}

#[test]
Expand Down
3 changes: 1 addition & 2 deletions rust/src/parser/src/tool/deepseek_json/deepseek_v31.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,7 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete DeepSeek V3.1 tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete DeepSeek V3.1 tool call"].assert_eq(&error.to_report_string());
}

#[test]
Expand Down
9 changes: 4 additions & 5 deletions rust/src/parser/src/tool/deepseek_json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use winnow::stream::Partial;
use winnow::token::{literal, rest, take_until};

use super::utils::{JsonObjectScanState, parse_buffered_event, safe_text_len, take_json_object};
use super::{Result, ToolCallDelta, ToolParserOutput};
use super::{Result, ToolCallDelta, ToolParserError, ToolParserOutput};

pub(super) const TOOL_CALLS_START: &str = "<|tool▁calls▁begin|>";
pub(super) const TOOL_CALLS_END: &str = "<|tool▁calls▁end|>";
Expand Down Expand Up @@ -162,10 +162,9 @@ impl DeepSeekJsonToolParser {
DeepSeekJsonMode::Text => output.push_text(&self.buffer),
DeepSeekJsonMode::ToolBlock | DeepSeekJsonMode::Done => {}
DeepSeekJsonMode::Header | DeepSeekJsonMode::Arguments { .. } => {
return Err(parsing_failed!(
"incomplete {} tool call",
self.format.parser_name()
));
return Err(ToolParserError::IncompleteToolCall {
parser: self.format.parser_name(),
});
}
}
let _ = self.reset();
Expand Down
4 changes: 4 additions & 0 deletions rust/src/parser/src/tool/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ pub type Result<T> = std::result::Result<T, ToolParserError>;
pub enum ToolParserError {
#[error("tool parser parsing failed: {message}")]
ParsingFailed { message: String },
/// The output ended inside a tool call, e.g. at `max_tokens` or a stop
/// string: the call's markup is truncated rather than malformed.
#[error("incomplete {parser} tool call")]
IncompleteToolCall { parser: &'static str },
#[error(
"`{name}` only provides a unified parser; the same reasoning parser and tool parser should be specified together"
)]
Expand Down
4 changes: 2 additions & 2 deletions rust/src/parser/src/tool/glm_xml/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use winnow::token::{literal, rest, take_until, take_while};

use super::parameters::ToolSchemas;
use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
use super::{Result, ToolCallDelta, ToolParserOutput};
use super::{Result, ToolCallDelta, ToolParserError, ToolParserOutput};
use crate::tool::Tool;

mod glm45_moe;
Expand Down Expand Up @@ -134,7 +134,7 @@ impl GlmXmlToolParser {
match self.mode {
GlmMode::Text => output.push_text(&self.buffer),
GlmMode::ToolCall { .. } => {
return Err(parsing_failed!("incomplete GLM MoE tool call"));
return Err(ToolParserError::IncompleteToolCall { parser: "GLM MoE" });
}
GlmMode::AfterToolCall => {}
}
Expand Down
13 changes: 5 additions & 8 deletions rust/src/parser/src/tool/hy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,9 @@ impl ToolParser for HyToolParser {
match self.mode {
HyMode::Text => output.push_text(&self.buffer),
HyMode::ToolBlock { .. } => {
return Err(parsing_failed!(
"incomplete {} tool call",
self.dialect.label()
));
return Err(ToolParserError::IncompleteToolCall {
parser: self.dialect.label(),
});
}
HyMode::Done => {}
}
Expand Down Expand Up @@ -711,8 +710,7 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete HY3 tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete HY3 tool call"].assert_eq(&error.to_report_string());
}

#[test]
Expand Down Expand Up @@ -785,8 +783,7 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete HY4 tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete HY4 tool call"].assert_eq(&error.to_report_string());
}

#[test]
Expand Down
7 changes: 3 additions & 4 deletions rust/src/parser/src/tool/json/granite4.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::tool::utils::{
JsonObjectScanState, JsonStringScanState, decode_json_str, parse_buffered_event, safe_text_len,
take_json_object, take_json_string,
};
use crate::tool::{Result, Tool, ToolCallDelta, ToolParser, ToolParserOutput};
use crate::tool::{Result, Tool, ToolCallDelta, ToolParser, ToolParserError, ToolParserOutput};

const TOOL_CALL_START: &str = "<tool_call>";
const TOOL_CALL_END: &str = "</tool_call>";
Expand Down Expand Up @@ -178,7 +178,7 @@ impl ToolParser for Granite4ToolParser {
match &self.mode {
Granite4Mode::Text => output.push_text(&self.buffer),
Granite4Mode::Header | Granite4Mode::Args { .. } | Granite4Mode::Close => {
return Err(parsing_failed!("incomplete Granite4 tool call"));
return Err(ToolParserError::IncompleteToolCall { parser: "Granite4" });
}
}
let _ = self.reset();
Expand Down Expand Up @@ -534,8 +534,7 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete Granite4 tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete Granite4 tool call"].assert_eq(&error.to_report_string());
}

#[test]
Expand Down
3 changes: 1 addition & 2 deletions rust/src/parser/src/tool/json/hermes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete Hermes tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete Hermes tool call"].assert_eq(&error.to_report_string());
}
}
10 changes: 6 additions & 4 deletions rust/src/parser/src/tool/json/llama.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use super::{
};
use crate::tool::utils::{JsonObjectScanState, parse_buffered_event};
use crate::tool::{
Result, StructuralTagBuilder, Tool, ToolCallDelta, ToolParser, ToolParserOutput,
Result, StructuralTagBuilder, Tool, ToolCallDelta, ToolParser, ToolParserError,
ToolParserOutput,
};

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -174,7 +175,9 @@ impl ToolParser for Llama3JsonToolParser {
}
LlamaJsonMode::AfterCall if self.buffer.trim().is_empty() => {}
LlamaJsonMode::Header | LlamaJsonMode::Arguments { .. } => {
return Err(parsing_failed!("incomplete Llama JSON tool call"));
return Err(ToolParserError::IncompleteToolCall {
parser: "Llama JSON",
});
}
LlamaJsonMode::AfterCall => {
return Err(parsing_failed!("invalid Llama JSON"));
Expand Down Expand Up @@ -495,8 +498,7 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete Llama JSON tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete Llama JSON tool call"].assert_eq(&error.to_report_string());
}

#[test]
Expand Down
3 changes: 1 addition & 2 deletions rust/src/parser/src/tool/json/mistral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,7 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete Mistral tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete Mistral tool call"].assert_eq(&error.to_report_string());
}

#[test]
Expand Down
9 changes: 4 additions & 5 deletions rust/src/parser/src/tool/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use super::utils::{
JsonObjectScanState, json_str, parse_buffered_event, safe_text_len, safe_text_len_mul,
take_json_object,
};
use super::{Result, ToolCallDelta, ToolParserOutput};
use super::{Result, ToolCallDelta, ToolParserError, ToolParserOutput};

pub(crate) type JsonToolInput<'i> = Partial<&'i str>;

Expand Down Expand Up @@ -118,10 +118,9 @@ impl JsonToolCallParser {
match &self.mode {
JsonToolCallMode::Text => output.push_text(&self.buffer),
JsonToolCallMode::Header | JsonToolCallMode::Arguments { .. } => {
return Err(parsing_failed!(
"incomplete {} tool call",
self.config.parser_name
));
return Err(ToolParserError::IncompleteToolCall {
parser: self.config.parser_name,
});
}
}
let _ = self.reset();
Expand Down
3 changes: 1 addition & 2 deletions rust/src/parser/src/tool/json/qwen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,7 @@ mod tests {

let error = parser.finish().unwrap_err();

expect!["tool parser parsing failed: incomplete Qwen3 JSON tool call"]
.assert_eq(&error.to_report_string());
expect!["incomplete Qwen3 JSON tool call"].assert_eq(&error.to_report_string());
}

#[test]
Expand Down
Loading