forked from Karylab-cklius/vllm
[Rust Frontend] Add reasoning/tool parser & renderer roundtrip tests (#43582)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
This commit is contained in:
Generated
+3
@@ -2591,6 +2591,7 @@ version = "2.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"memo-map",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5608,6 +5609,7 @@ dependencies = [
|
||||
"minijinja",
|
||||
"minijinja-contrib",
|
||||
"openai-harmony",
|
||||
"paste",
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
@@ -5810,6 +5812,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"serial_test",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror-ext",
|
||||
|
||||
+4
-3
@@ -45,13 +45,14 @@ http-body = "1.0.1"
|
||||
itertools = "0.14.0"
|
||||
libc = "0.2.177"
|
||||
llm-multimodal = { git = "https://github.com/vllm-project/llm-multimodal", rev = "5b558989844d1c7af3e43d0f604069ffd9c06320" }
|
||||
minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls"] }
|
||||
minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
|
||||
minijinja-contrib = { version = "2.0", features = ["pycompat"] }
|
||||
native-tls-vendored = { package = "native-tls", version = "0.2.18", features = ["vendored"] }
|
||||
ndarray = { version = "0.16.1", features = ["serde"] }
|
||||
openai-harmony = "0.0.8"
|
||||
openai-protocol = "1.6.0"
|
||||
parking_lot = "0.12.5"
|
||||
paste = "1.0.15"
|
||||
prometheus-client = "0.24.0"
|
||||
prometheus-client-derive-encode = "0.5.0"
|
||||
prost = "0.14.3"
|
||||
@@ -65,11 +66,11 @@ rustc-hash = "1.1.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde-json-fmt = "0.1.0"
|
||||
serde_default = "0.2.0"
|
||||
serde_json = "1.0.145"
|
||||
serde_json = { version = "1.0.145", features = ["arbitrary_precision", "preserve_order"] }
|
||||
serde_repr = "0.1.20"
|
||||
serde_tuple = "1.1.3"
|
||||
serde_with = "3.18.0"
|
||||
serial_test = "3.2.0"
|
||||
serial_test = { version = "3.2.0", features = ["file_locks"] }
|
||||
socket2 = "0.6.3"
|
||||
subenum = "1.1.3"
|
||||
task-local = "0.1.1"
|
||||
|
||||
@@ -39,8 +39,9 @@ anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
clap.workspace = true
|
||||
expect-test.workspace = true
|
||||
paste.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
serial_test = { workspace = true, features = ["file_locks"] }
|
||||
serial_test.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
@@ -53,6 +53,25 @@ impl AssistantContentBlock {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a copy of this block with leading and trailing whitespace trimmed from all text
|
||||
/// fields and tool call arguments, or `None` if the resulting text would be empty.
|
||||
pub fn trim(mut self) -> Option<Self> {
|
||||
match &mut self {
|
||||
Self::Text { text } | Self::Reasoning { text } => {
|
||||
let trimmed_text = text.trim();
|
||||
if trimmed_text.is_empty() {
|
||||
return None;
|
||||
} else {
|
||||
*text = trimmed_text.to_string();
|
||||
}
|
||||
}
|
||||
Self::ToolCall(call) => {
|
||||
call.arguments = call.arguments.trim().to_string();
|
||||
}
|
||||
}
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[easy_ext::ext(AssistantMessageExt)]
|
||||
@@ -119,6 +138,13 @@ impl AssistantMessage {
|
||||
pub(crate) fn push_block(&mut self, block: AssistantContentBlock) {
|
||||
self.content.push(block);
|
||||
}
|
||||
|
||||
/// Return a copy of this message with leading and trailing whitespace trimmed from all text
|
||||
/// fields and tool call arguments, and with any blocks that are empty after trimming removed.
|
||||
pub fn trim(mut self) -> Self {
|
||||
self.content = self.content.into_iter().filter_map(|block| block.trim()).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Streamed chat event emitted by [`crate::ChatEventStream`].
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use minijinja::value::{Kwargs, ViaDeserialize};
|
||||
use minijinja::{Error as MinijinjaError, ErrorKind, Value};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{self, Value as JsonValue};
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json_fmt::{JsonFormat, JsonSyntaxError};
|
||||
use thiserror_ext::AsReport;
|
||||
|
||||
@@ -13,7 +13,7 @@ use thiserror_ext::AsReport;
|
||||
/// - extra kwargs such as `ensure_ascii`, `separators`, and `sort_keys`
|
||||
/// - Python-style `indent` handling
|
||||
pub(super) fn hf_tojson_filter(
|
||||
value: Value,
|
||||
ViaDeserialize(value): ViaDeserialize<JsonValue>,
|
||||
kwargs: Kwargs,
|
||||
) -> std::result::Result<Value, MinijinjaError> {
|
||||
let ensure_ascii = kwargs.get::<Option<bool>>("ensure_ascii")?.unwrap_or(false);
|
||||
@@ -30,18 +30,11 @@ pub(super) fn hf_tojson_filter(
|
||||
|
||||
kwargs.assert_all_used()?;
|
||||
|
||||
let json_value: serde_json::Value = serde_json::to_value(&value).map_err(|e| {
|
||||
MinijinjaError::new(
|
||||
ErrorKind::InvalidOperation,
|
||||
format!("Failed to convert to JSON value: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let json_str = {
|
||||
let value_to_serialize = if sort_keys {
|
||||
&sort_json_keys(&json_value)
|
||||
&sort_json_keys(&value)
|
||||
} else {
|
||||
&json_value
|
||||
&value
|
||||
};
|
||||
|
||||
build_json_format(indent, separators.0, separators.1, ensure_ascii)?
|
||||
@@ -214,6 +207,14 @@ mod tests {
|
||||
assert_eq!(rendered, "{\"x\":[1,2]}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tojson_preserves_arbitrary_precision_number_spelling() {
|
||||
let payload = serde_json::from_str(r#"{"x":2,"y":1.00}"#).unwrap();
|
||||
let rendered = render("{{ payload|tojson }}", payload);
|
||||
|
||||
assert_eq!(rendered, "{\"x\": 2, \"y\": 1.00}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tojson_supports_negative_indent_as_newline_only() {
|
||||
let rendered = render("{{ payload|tojson(indent=-1) }}", json!([1, 2]));
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
//! Text-level roundtrip tests for the real chat-template and output-processor pairing.
|
||||
//!
|
||||
//! The invariant under test is that a structured assistant message rendered as history can be
|
||||
//! parsed from the generated assistant completion and then rendered back to the exact same
|
||||
//! assistant-completion text.
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use futures::{Stream, StreamExt as _, stream};
|
||||
use serde_json_fmt::JsonFormat as JsonFmt;
|
||||
use serial_test::file_serial;
|
||||
use vllm_chat::{
|
||||
AssistantContentBlock, AssistantMessage, AssistantMessageExt as _, AssistantToolCall,
|
||||
ChatEvent, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice, FinishReason,
|
||||
GenerationPromptMode, LoadModelBackendsOptions, NewChatOutputProcessorOptions, ParserSelection,
|
||||
RendererSelection, load_model_backends,
|
||||
};
|
||||
use vllm_text::{DecodedTextEvent, Finished, Prompt};
|
||||
|
||||
/// One model/parser configuration used to run the fixed roundtrip fixtures.
|
||||
struct RoundtripCase {
|
||||
/// Hugging Face model id resolved through the production backend loader.
|
||||
model_id: &'static str,
|
||||
/// Final assistant-history suffix rendered by the chat template but not
|
||||
/// generated by the model body consumed by the output processor.
|
||||
// TODO: we should adopt `ContinueFinalAssistant` mode to naturally handle this.
|
||||
assistant_stop_suffix: &'static str,
|
||||
/// Tool parser selection used by the output processor.
|
||||
tool_call_parser: ParserSelection,
|
||||
/// Reasoning parser selection used by the output processor.
|
||||
reasoning_parser: ParserSelection,
|
||||
/// JSON formatting expected after this model's template has materialized
|
||||
/// tool-call arguments.
|
||||
json_fmt: JsonFmt,
|
||||
}
|
||||
|
||||
impl RoundtripCase {
|
||||
/// Qwen3 XML tool-call format with `qwen3` reasoning tags.
|
||||
fn qwen3() -> Self {
|
||||
Self {
|
||||
model_id: "Qwen/Qwen3-0.6B",
|
||||
assistant_stop_suffix: "<|im_end|>\n",
|
||||
tool_call_parser: ParserSelection::Auto,
|
||||
reasoning_parser: ParserSelection::Auto,
|
||||
json_fmt: spaced_json_fmt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Qwen3.5 coder-style JSON tool-call format with `qwen3` reasoning tags.
|
||||
fn qwen35() -> Self {
|
||||
Self {
|
||||
model_id: "Qwen/Qwen3.5-4B",
|
||||
assistant_stop_suffix: "<|im_end|>\n",
|
||||
tool_call_parser: ParserSelection::Auto,
|
||||
reasoning_parser: ParserSelection::Auto,
|
||||
json_fmt: compact_json_fmt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// MiniMax M2.5 XML invoke format with `<think>` reasoning tags.
|
||||
fn minimax_m25() -> Self {
|
||||
Self {
|
||||
model_id: "MiniMaxAI/MiniMax-M2.5",
|
||||
assistant_stop_suffix: "[e~[\n",
|
||||
tool_call_parser: ParserSelection::Auto,
|
||||
reasoning_parser: ParserSelection::Auto,
|
||||
json_fmt: compact_json_fmt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// DeepSeek V4 DSML tool-call format.
|
||||
fn deepseek_v4() -> Self {
|
||||
Self {
|
||||
model_id: "deepseek-ai/DeepSeek-V4-Flash",
|
||||
assistant_stop_suffix: "<|end▁of▁sentence|>",
|
||||
tool_call_parser: ParserSelection::Auto,
|
||||
reasoning_parser: ParserSelection::Auto,
|
||||
json_fmt: compact_json_fmt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GLM-4.7 XML-like argument format with `<think>` reasoning tags.
|
||||
fn glm47() -> Self {
|
||||
Self {
|
||||
model_id: "zai-org/GLM-4.7-Flash",
|
||||
assistant_stop_suffix: "",
|
||||
tool_call_parser: ParserSelection::Auto,
|
||||
reasoning_parser: ParserSelection::Auto,
|
||||
json_fmt: compact_json_fmt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Kimi K2.5 tool-call format with `<think>` reasoning tags.
|
||||
#[allow(dead_code)]
|
||||
fn kimi_k25() -> Self {
|
||||
Self {
|
||||
model_id: "moonshotai/Kimi-K2.5",
|
||||
assistant_stop_suffix: "<|im_end|>",
|
||||
tool_call_parser: ParserSelection::Auto,
|
||||
reasoning_parser: ParserSelection::Auto,
|
||||
json_fmt: spaced_json_fmt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! roundtrip_tests {
|
||||
($($case:ident => [$($fixture:ident),* $(,)?]),+ $(,)?) => {
|
||||
paste::paste! {
|
||||
$(
|
||||
$(
|
||||
#[tokio::test]
|
||||
#[file_serial([<hf_ $case>])]
|
||||
async fn [<roundtrip_ $case _ $fixture>]() -> Result<()> {
|
||||
[<run_roundtrip_ $fixture>](RoundtripCase::$case()).await
|
||||
}
|
||||
)*
|
||||
)+
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
roundtrip_tests! {
|
||||
qwen3 => [reasoning_and_content, tool_call_mix],
|
||||
qwen35 => [reasoning_and_content, tool_call_mix],
|
||||
minimax_m25 => [reasoning_and_content, tool_call_mix],
|
||||
deepseek_v4 => [reasoning_and_content, tool_call_mix],
|
||||
glm47 => [reasoning_and_content, tool_call_mix],
|
||||
|
||||
// Note: Kimi K2.5 strips the reasoning content in history.
|
||||
// TODO: we don't respect model-generated tool call id now so `tool_call_mix` cannot pass.
|
||||
// kimi_k25 => [tool_call_mix],
|
||||
}
|
||||
|
||||
/// Run the fixed reasoning+content fixture for one model/parser case.
|
||||
async fn run_roundtrip_reasoning_and_content(case: RoundtripCase) -> Result<()> {
|
||||
let backends = load_roundtrip_backends(&case).await?;
|
||||
let request = roundtrip_request(
|
||||
"roundtrip-reasoning-content",
|
||||
vec![ChatMessage::text(ChatRole::User, "What is 2 + 2?")],
|
||||
Vec::new(),
|
||||
);
|
||||
let expected_reasoning = "Need compute 2 + 2 directly.";
|
||||
let expected_text = "The answer is 4.";
|
||||
|
||||
let result = run_roundtrip(
|
||||
&case,
|
||||
&backends,
|
||||
&request,
|
||||
AssistantMessage {
|
||||
content: vec![
|
||||
AssistantContentBlock::Reasoning {
|
||||
text: expected_reasoning.to_string(),
|
||||
},
|
||||
AssistantContentBlock::Text {
|
||||
text: expected_text.to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
result.parsed_message.reasoning().as_deref().map(str::trim),
|
||||
Some(expected_reasoning)
|
||||
);
|
||||
assert_eq!(result.parsed_message.text().trim(), expected_text);
|
||||
assert_eq!(result.parsed_message.tool_calls().count(), 0);
|
||||
|
||||
assert_eq!(
|
||||
result.rerendered_closed_completion,
|
||||
result.closed_completion
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the fixed reasoning+multiple-tools fixture for one model/parser case.
|
||||
async fn run_roundtrip_tool_call_mix(case: RoundtripCase) -> Result<()> {
|
||||
let backends = load_roundtrip_backends(&case).await?;
|
||||
let request = roundtrip_request(
|
||||
"roundtrip-reasoning-tools",
|
||||
vec![ChatMessage::text(
|
||||
ChatRole::User,
|
||||
"Check Shanghai weather and add 1.00 plus 2.",
|
||||
)],
|
||||
test_tools(),
|
||||
);
|
||||
let expected_reasoning = "Need call the weather and add tools.";
|
||||
let expected_text = "I will call the tools.";
|
||||
|
||||
let result = run_roundtrip(
|
||||
&case,
|
||||
&backends,
|
||||
&request,
|
||||
AssistantMessage {
|
||||
content: vec![
|
||||
AssistantContentBlock::Reasoning {
|
||||
text: expected_reasoning.to_string(),
|
||||
},
|
||||
AssistantContentBlock::Text {
|
||||
text: expected_text.to_string(),
|
||||
},
|
||||
AssistantContentBlock::ToolCall(AssistantToolCall {
|
||||
id: "functions.get_weather:0".to_string(),
|
||||
name: "get_weather".to_string(),
|
||||
arguments: r#"{"location":"Shanghai"}"#.to_string(),
|
||||
}),
|
||||
AssistantContentBlock::ToolCall(AssistantToolCall {
|
||||
id: "functions.add:1".to_string(),
|
||||
name: "add".to_string(),
|
||||
// Intentionally use a non-lexical order of keys and a different number
|
||||
// formatting style to verify text-level fidelity of the roundtrip.
|
||||
arguments: r#"{"y":1.00,"x":2}"#.to_string(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
result.parsed_message.reasoning().as_deref().map(str::trim),
|
||||
Some(expected_reasoning)
|
||||
);
|
||||
assert_eq!(result.parsed_message.text().trim(), expected_text);
|
||||
|
||||
let tool_calls = result.parsed_message.tool_calls().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
tool_calls.len(),
|
||||
2,
|
||||
"parsed message: {:#?}",
|
||||
result.parsed_message
|
||||
);
|
||||
assert_eq!(tool_calls[0].name, "get_weather");
|
||||
assert_eq!(
|
||||
tool_calls[0].arguments,
|
||||
expected_arguments(&case, r#"{"location": "Shanghai"}"#)?,
|
||||
);
|
||||
assert_eq!(tool_calls[1].name, "add");
|
||||
assert_eq!(
|
||||
tool_calls[1].arguments,
|
||||
expected_arguments(&case, r#"{"y": 1.00, "x": 2}"#)?,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.rerendered_closed_completion,
|
||||
result.closed_completion
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compact JSON argument formatting used by JSON-native parsers/renderers.
|
||||
fn compact_json_fmt() -> JsonFmt {
|
||||
JsonFmt::new()
|
||||
}
|
||||
|
||||
/// Python `json.dumps`-style compact formatting with a space after commas and
|
||||
/// colons.
|
||||
fn spaced_json_fmt() -> JsonFmt {
|
||||
JsonFmt::new()
|
||||
.comma(", ")
|
||||
.expect("literal comma separator is valid JSON")
|
||||
.colon(": ")
|
||||
.expect("literal colon separator is valid JSON")
|
||||
}
|
||||
|
||||
/// Parse and format expected tool-call arguments from raw JSON text.
|
||||
/// Pass in a raw JSON string instead of a structured value to ensure the exact precision and
|
||||
/// formatting of numbers are preserved.
|
||||
fn expected_arguments(case: &RoundtripCase, raw_json: &str) -> Result<String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(raw_json).context("invalid expected tool-call arguments")?;
|
||||
|
||||
case.json_fmt
|
||||
.format_to_string(&value)
|
||||
.context("failed to format expected tool-call arguments")
|
||||
}
|
||||
|
||||
/// Load the real model chat/text backend for one roundtrip case.
|
||||
async fn load_roundtrip_backends(case: &RoundtripCase) -> Result<vllm_chat::LoadedModelBackends> {
|
||||
load_model_backends(
|
||||
case.model_id,
|
||||
LoadModelBackendsOptions {
|
||||
renderer: RendererSelection::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("failed to load HF model files for {}", case.model_id))
|
||||
}
|
||||
|
||||
/// Roundtrip artifacts needed for semantic and exact-text assertions.
|
||||
struct RoundtripResult {
|
||||
/// Final assistant message reconstructed by the output processor.
|
||||
parsed_message: AssistantMessage,
|
||||
/// Assistant-completion suffix cut from rendering the expected assistant as
|
||||
/// history.
|
||||
closed_completion: String,
|
||||
/// Assistant-completion suffix cut after rendering the parsed assistant
|
||||
/// back as history.
|
||||
rerendered_closed_completion: String,
|
||||
}
|
||||
|
||||
/// Render, parse, and rerender one assistant turn through the production
|
||||
/// renderer/output-processor boundary.
|
||||
async fn run_roundtrip(
|
||||
case: &RoundtripCase,
|
||||
backends: &vllm_chat::LoadedModelBackends,
|
||||
request: &ChatRequest,
|
||||
assistant: AssistantMessage,
|
||||
) -> Result<RoundtripResult> {
|
||||
let renderer = backends.chat_backend.chat_renderer();
|
||||
let (prompt, closed_completion_text) =
|
||||
render_closed_completion(renderer.as_ref(), request, &assistant)?;
|
||||
let completion_body = closed_completion_text
|
||||
.strip_suffix(case.assistant_stop_suffix)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"closed assistant completion did not end with {:?}: {:?}",
|
||||
case.assistant_stop_suffix, closed_completion_text
|
||||
)
|
||||
})?;
|
||||
|
||||
let parsed_message =
|
||||
parse_completion(case, backends, request, &prompt, completion_body).await?;
|
||||
let (_, rerendered_closed_completion) =
|
||||
render_closed_completion(renderer.as_ref(), request, &parsed_message)?;
|
||||
|
||||
Ok(RoundtripResult {
|
||||
parsed_message,
|
||||
closed_completion: closed_completion_text,
|
||||
rerendered_closed_completion,
|
||||
})
|
||||
}
|
||||
|
||||
/// Render `history` as a production prompt and `history + assistant` as closed
|
||||
/// history, then return the production prompt and assistant-completion suffix.
|
||||
fn render_closed_completion(
|
||||
renderer: &dyn vllm_chat::ChatRenderer,
|
||||
base_request: &ChatRequest,
|
||||
assistant: &AssistantMessage,
|
||||
) -> Result<(String, String)> {
|
||||
let mut prompt_request = base_request.clone();
|
||||
prompt_request.chat_options.generation_prompt_mode = GenerationPromptMode::StartNewAssistant;
|
||||
let prompt = render_text(renderer, &prompt_request).context("failed to render prompt")?;
|
||||
|
||||
let mut full_request = base_request.clone();
|
||||
full_request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
|
||||
full_request.messages.push(ChatMessage::from(assistant.clone()));
|
||||
let full = render_text(renderer, &full_request).context("failed to render full prompt")?;
|
||||
|
||||
ensure!(
|
||||
full.starts_with(&prompt),
|
||||
"full prompt must extend production prompt\nprompt: {prompt:?}\nfull: {full:?}"
|
||||
);
|
||||
let completion = full[prompt.len()..].to_string();
|
||||
|
||||
Ok((prompt, completion))
|
||||
}
|
||||
|
||||
/// Render one chat request and require a text prompt.
|
||||
fn render_text(renderer: &dyn vllm_chat::ChatRenderer, request: &ChatRequest) -> Result<String> {
|
||||
match renderer.render(request)?.prompt {
|
||||
Prompt::Text(text) => Ok(text),
|
||||
other => bail!("roundtrip tests expect text prompts, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one rendered assistant completion body into the real output processor
|
||||
/// and collect its terminal assistant message.
|
||||
async fn parse_completion(
|
||||
case: &RoundtripCase,
|
||||
backends: &vllm_chat::LoadedModelBackends,
|
||||
base_request: &ChatRequest,
|
||||
prompt: &str,
|
||||
completion_body: &str,
|
||||
) -> Result<AssistantMessage> {
|
||||
let tokenizer = backends.text_backend.tokenizer();
|
||||
let prompt_token_ids = tokenizer
|
||||
.encode(prompt, base_request.add_special_tokens)
|
||||
.context("failed to encode rendered prompt")?;
|
||||
|
||||
let mut request = base_request.clone();
|
||||
let processor = backends.chat_backend.new_chat_output_processor(
|
||||
&mut request,
|
||||
NewChatOutputProcessorOptions {
|
||||
tool_call_parser: &case.tool_call_parser,
|
||||
reasoning_parser: &case.reasoning_parser,
|
||||
},
|
||||
)?;
|
||||
|
||||
let decoded = decoded_completion_stream(prompt_token_ids, completion_body);
|
||||
let mut events = processor.process(decoded)?;
|
||||
|
||||
while let Some(event) = events.next().await {
|
||||
if let ChatEvent::Done { message, .. } = event? {
|
||||
// TODO: currently our parsers are not very strict about preserving or trimming
|
||||
// whitespace, so we trim here to avoid roundtrip failures due to
|
||||
// insignificant whitespace differences. However, this may hurt token-level
|
||||
// fidelity so we should consider improving them.
|
||||
return Ok(message.trim());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("output processor finished without a Done event")
|
||||
}
|
||||
|
||||
/// Build a decoded-text stream from an already-rendered completion body.
|
||||
///
|
||||
/// The first event carries real prompt token ids so reasoning parsers can
|
||||
/// initialize from the same prompt boundary production uses. Completion text is
|
||||
/// split into small chunks to exercise streaming parser state across marker
|
||||
/// and JSON boundaries.
|
||||
fn decoded_completion_stream(
|
||||
prompt_token_ids: Vec<u32>,
|
||||
completion_body: &str,
|
||||
) -> Pin<Box<dyn Stream<Item = vllm_chat::Result<DecodedTextEvent>> + Send>> {
|
||||
let prompt_token_count = prompt_token_ids.len();
|
||||
let mut events = vec![DecodedTextEvent::Start {
|
||||
prompt_token_ids: Arc::from(prompt_token_ids.into_boxed_slice()),
|
||||
prompt_logprobs: None,
|
||||
}];
|
||||
|
||||
let chunks = split_by_chars(completion_body, 7);
|
||||
if chunks.is_empty() {
|
||||
events.push({
|
||||
DecodedTextEvent::TextDelta {
|
||||
delta: String::new(),
|
||||
token_ids: Vec::new(),
|
||||
logprobs: None,
|
||||
finished: Some(Finished {
|
||||
prompt_token_count: 0,
|
||||
output_token_count: 0,
|
||||
finish_reason: FinishReason::stop_eos(),
|
||||
kv_transfer_params: None,
|
||||
}),
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let last_index = chunks.len() - 1;
|
||||
for (index, chunk) in chunks.into_iter().enumerate() {
|
||||
let finished = (index == last_index).then(|| Finished {
|
||||
prompt_token_count,
|
||||
output_token_count: completion_body.chars().count(),
|
||||
finish_reason: FinishReason::stop_eos(),
|
||||
kv_transfer_params: None,
|
||||
});
|
||||
events.push(DecodedTextEvent::TextDelta {
|
||||
delta: chunk,
|
||||
token_ids: Vec::new(),
|
||||
logprobs: None,
|
||||
finished,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stream::iter(events).map(Ok).boxed()
|
||||
}
|
||||
|
||||
/// Split text into chunks containing at most `chunk_chars` Unicode scalar
|
||||
/// values.
|
||||
fn split_by_chars(text: &str, chunk_chars: usize) -> Vec<String> {
|
||||
let mut chunks = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut count = 0;
|
||||
|
||||
for (index, _) in text.char_indices() {
|
||||
if count == chunk_chars {
|
||||
chunks.push(text[start..index].to_string());
|
||||
start = index;
|
||||
count = 0;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if start < text.len() {
|
||||
chunks.push(text[start..].to_string());
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
/// Build a chat request fixture with parser-enabling tool-choice semantics.
|
||||
fn roundtrip_request(
|
||||
request_id: impl Into<String>,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<ChatTool>,
|
||||
) -> ChatRequest {
|
||||
let mut request = ChatRequest {
|
||||
request_id: request_id.into(),
|
||||
messages,
|
||||
tool_choice: if tools.is_empty() {
|
||||
ChatToolChoice::None
|
||||
} else {
|
||||
ChatToolChoice::Auto
|
||||
},
|
||||
tools,
|
||||
..ChatRequest::for_test()
|
||||
};
|
||||
|
||||
// Enable thinking for some models so that rendering and parsing the reasoning block is
|
||||
// exercised in the roundtrip.
|
||||
for key in ["thinking", "enable_thinking"] {
|
||||
request.chat_options.template_kwargs.insert(key.to_string(), true.into());
|
||||
}
|
||||
|
||||
request
|
||||
}
|
||||
|
||||
/// Return the function tools used by the multiple-tool-call fixture.
|
||||
fn test_tools() -> Vec<ChatTool> {
|
||||
vec![
|
||||
ChatTool {
|
||||
name: "get_weather".to_string(),
|
||||
description: Some("Get weather for a location".to_string()),
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": { "type": "string" }
|
||||
},
|
||||
"required": ["location"]
|
||||
}),
|
||||
strict: None,
|
||||
},
|
||||
ChatTool {
|
||||
name: "add".to_string(),
|
||||
description: Some("Add two integers".to_string()),
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"y": { "type": "number" },
|
||||
"x": { "type": "number" }
|
||||
},
|
||||
"required": ["y", "x"]
|
||||
}),
|
||||
strict: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -26,6 +26,7 @@ vllm-tokenizer.workspace = true
|
||||
[dev-dependencies]
|
||||
expect-test.workspace = true
|
||||
futures.workspace = true
|
||||
serial_test.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio.workspace = true
|
||||
vllm-llm = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -401,7 +401,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires network access to Hugging Face and downloads the real Kimi K2.5 tokenizer"]
|
||||
#[ignore = "too slow for CI and requires network access to Hugging Face"]
|
||||
async fn tiktoken_real_kimi_k25_tokenizer_files_load_and_handle_special_tokens() {
|
||||
let files = ResolvedModelFiles::new("moonshotai/Kimi-K2.5")
|
||||
.await
|
||||
|
||||
@@ -235,6 +235,8 @@ fn merge_unique_token_ids(
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serial_test::file_serial;
|
||||
|
||||
use super::*;
|
||||
use crate::backend::hf::HfTextBackend;
|
||||
use crate::backend::{SamplingHints, TextBackend as _};
|
||||
@@ -386,7 +388,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires network access to Hugging Face"]
|
||||
#[file_serial(hf_qwen3)]
|
||||
async fn lower_text_request_uses_real_qwen_generation_defaults() {
|
||||
let backend = HfTextBackend::from_model("Qwen/Qwen3-0.6B")
|
||||
.await
|
||||
@@ -410,12 +412,8 @@ mod tests {
|
||||
default_top_k: Some(
|
||||
20,
|
||||
),
|
||||
default_min_p: Some(
|
||||
0.1,
|
||||
),
|
||||
default_repetition_penalty: Some(
|
||||
1.2,
|
||||
),
|
||||
default_min_p: None,
|
||||
default_repetition_penalty: None,
|
||||
default_max_tokens: None,
|
||||
max_model_len: Some(
|
||||
40960,
|
||||
@@ -439,10 +437,10 @@ mod tests {
|
||||
min_tokens: 0,
|
||||
logprobs: None,
|
||||
prompt_logprobs: None,
|
||||
min_p: 0.1,
|
||||
min_p: 0.0,
|
||||
frequency_penalty: 0.0,
|
||||
presence_penalty: 0.0,
|
||||
repetition_penalty: 1.2,
|
||||
repetition_penalty: 1.0,
|
||||
stop_token_ids: [
|
||||
151643,
|
||||
],
|
||||
@@ -453,6 +451,13 @@ mod tests {
|
||||
151643,
|
||||
151645,
|
||||
},
|
||||
logit_bias: None,
|
||||
allowed_token_ids: None,
|
||||
bad_words_token_ids: None,
|
||||
structured_outputs: None,
|
||||
logprob_token_ids: None,
|
||||
skip_reading_prefix_cache: None,
|
||||
extra_args: None,
|
||||
}
|
||||
"#]]
|
||||
.assert_debug_eq(¶ms);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
|
||||
use winnow::combinator::{alt, delimited, repeat, seq, terminated};
|
||||
use winnow::combinator::{alt, delimited, eof, repeat, seq, terminated};
|
||||
use winnow::prelude::*;
|
||||
use winnow::stream::Partial;
|
||||
use winnow::token::{literal, rest, take_until};
|
||||
@@ -183,16 +183,17 @@ fn tool_block_end_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2
|
||||
|
||||
/// Parse a complete MiniMax M2 invoke block.
|
||||
fn invoke_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
|
||||
let (name, raw_params) = seq!(
|
||||
let (name, body) = seq!(
|
||||
_: ws0,
|
||||
_: literal(INVOKE_START),
|
||||
_: (ws1, literal("name=")),
|
||||
attr_value,
|
||||
partial_attr_value,
|
||||
_: literal(">"),
|
||||
repeat(0.., terminated(parameter, ws0)),
|
||||
take_until(0.., INVOKE_END),
|
||||
_: literal(INVOKE_END),
|
||||
)
|
||||
.parse_next(input)?;
|
||||
let raw_params = parse_invoke_params(body)?;
|
||||
|
||||
Ok(MinimaxM2Event::Invoke {
|
||||
name: name.trim().to_string(),
|
||||
@@ -200,8 +201,14 @@ fn invoke_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse all parameter blocks inside a complete MiniMax M2 invoke body.
|
||||
fn parse_invoke_params(invoke_body: &str) -> ModalResult<Vec<(String, String)>> {
|
||||
let mut input = invoke_body;
|
||||
delimited(ws0, repeat(0.., terminated(parameter, ws0)), eof).parse_next(&mut input)
|
||||
}
|
||||
|
||||
/// Parse a MiniMax M2 parameter block.
|
||||
fn parameter(input: &mut MinimaxM2Input<'_>) -> ModalResult<(String, String)> {
|
||||
fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
|
||||
let (name, value) = seq!(
|
||||
_: literal(PARAMETER_START),
|
||||
_: (ws1, literal("name=")),
|
||||
@@ -216,7 +223,17 @@ fn parameter(input: &mut MinimaxM2Input<'_>) -> ModalResult<(String, String)> {
|
||||
}
|
||||
|
||||
/// Parse a quoted or unquoted XML attribute value.
|
||||
fn attr_value<'i>(input: &mut MinimaxM2Input<'i>) -> ModalResult<&'i str> {
|
||||
fn attr_value<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
|
||||
alt((
|
||||
delimited(literal("\""), take_until(1.., "\""), literal("\"")),
|
||||
delimited(literal("'"), take_until(1.., "'"), literal("'")),
|
||||
take_until(1.., ">"),
|
||||
))
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a quoted or unquoted XML attribute value from partial streaming input.
|
||||
fn partial_attr_value<'i>(input: &mut MinimaxM2Input<'i>) -> ModalResult<&'i str> {
|
||||
alt((
|
||||
delimited(literal("\""), take_until(1.., "\""), literal("\"")),
|
||||
delimited(literal("'"), take_until(1.., "'"), literal("'")),
|
||||
@@ -470,6 +487,31 @@ mod tests {
|
||||
assert_eq!(result.calls[1].tool_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_m2_streaming_handles_template_whitespace_and_split_parameters() {
|
||||
let text = concat!(
|
||||
"I will call the tools.\n",
|
||||
"<minimax:tool_call>\n",
|
||||
"<invoke name=\"get_weather\">\n",
|
||||
"<parameter name=\"city\">Seattle</parameter>\n",
|
||||
"</invoke>\n",
|
||||
"<invoke name=\"get_weather\">\n",
|
||||
"<parameter name=\"city\">NYC</parameter>\n",
|
||||
"</invoke>\n",
|
||||
"</minimax:tool_call>",
|
||||
);
|
||||
let chunks = split_by_chars(text, 7);
|
||||
let mut parser = MinimaxM2ToolParser::new(&test_tools());
|
||||
let result = collect_stream(&mut parser, &chunks);
|
||||
|
||||
assert_eq!(result.normal_text, "I will call the tools.\n");
|
||||
assert_eq!(result.calls.len(), 2);
|
||||
assert_eq!(result.calls[0].tool_index, 0);
|
||||
assert_eq!(result.calls[0].name.as_deref(), Some("get_weather"));
|
||||
assert_eq!(result.calls[1].tool_index, 1);
|
||||
assert_eq!(result.calls[1].name.as_deref(), Some("get_weather"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_m2_streaming_ignores_text_after_tool_block() {
|
||||
let text = format!(
|
||||
|
||||
@@ -224,10 +224,11 @@ fn convert_value(param_type: &JsonParamType, value: &str) -> Option<Value> {
|
||||
|
||||
/// Convert one raw string value to a JSON number.
|
||||
fn convert_number(value: &str) -> Option<Value> {
|
||||
if let Ok(parsed) = value.parse::<i64>() {
|
||||
return Some(Value::Number(Number::from(parsed)));
|
||||
}
|
||||
Number::from_f64(value.parse::<f64>().ok()?).map(Value::Number)
|
||||
serde_json::from_str::<Number>(value)
|
||||
.or_else(|_| value.parse::<i64>().map(Number::from))
|
||||
.or_else(|_| value.parse::<f64>().ok().and_then(Number::from_f64).ok_or(()))
|
||||
.ok()
|
||||
.map(Value::Number)
|
||||
}
|
||||
|
||||
/// Convert one raw string value to a boolean.
|
||||
@@ -304,7 +305,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_conversion_parses_int_then_float() {
|
||||
fn number_conversion_preserves_json_number_spelling_with_legacy_fallback() {
|
||||
let params = ToolSchema::from_schema(&json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -312,17 +313,23 @@ mod tests {
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(params.convert("value", "5"), json!(5));
|
||||
assert_eq!(params.convert("value", "5.0"), json!(5.0));
|
||||
assert_eq!(params.convert("value", "5."), json!(5.0));
|
||||
assert_eq!(params.convert("value", "+1"), json!(1));
|
||||
assert_eq!(params.convert("value", "+1.0"), json!(1.0));
|
||||
assert_eq!(converted_number_text(¶ms, "5"), "5");
|
||||
assert_eq!(converted_number_text(¶ms, "5.0"), "5.0");
|
||||
assert_eq!(converted_number_text(¶ms, "5.00"), "5.00");
|
||||
assert_eq!(converted_number_text(¶ms, "1e0"), "1e+0");
|
||||
assert_eq!(converted_number_text(¶ms, "5."), "5.0");
|
||||
assert_eq!(converted_number_text(¶ms, "+1"), "1");
|
||||
assert_eq!(converted_number_text(¶ms, "+1.0"), "1.0");
|
||||
assert_eq!(
|
||||
params.convert("value", "9223372036854775807.5"),
|
||||
json!(9223372036854775808.0)
|
||||
converted_number_text(¶ms, "9223372036854775807.5"),
|
||||
"9223372036854775807.5"
|
||||
);
|
||||
}
|
||||
|
||||
fn converted_number_text(params: &ToolSchema, value: &str) -> String {
|
||||
serde_json::to_string(¶ms.convert("value", value)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_upstream_aliases() {
|
||||
let params = ToolSchema::from_schema(&json!({
|
||||
|
||||
Reference in New Issue
Block a user