forked from Karylab-cklius/vllm
[Rust Frontend] Add Phi-4 mini JSON tool parser (#44213)
This commit is contained in:
@@ -234,7 +234,7 @@ mod tests {
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, hermes, hy_v3, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, mistral, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string());
|
||||
expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, hermes, hy_v3, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, mistral, phi4_mini_json, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,8 +6,8 @@ pub use vllm_tool_parser::{
|
||||
DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
|
||||
Gemma4ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, HermesToolParser, HyV3ToolParser,
|
||||
Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser,
|
||||
MistralToolParser, Qwen3CoderToolParser, Qwen3XmlToolParser, ToolCallDelta, ToolParser,
|
||||
ToolParserError, ToolParserOutput,
|
||||
MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser, Qwen3XmlToolParser,
|
||||
ToolCallDelta, ToolParser, ToolParserError, ToolParserOutput,
|
||||
};
|
||||
|
||||
use crate::parser::ParserFactory;
|
||||
@@ -32,6 +32,7 @@ pub mod names {
|
||||
pub const LLAMA4_JSON: &str = "llama4_json";
|
||||
pub const MINIMAX_M2: &str = "minimax_m2";
|
||||
pub const MISTRAL: &str = "mistral";
|
||||
pub const PHI4_MINI_JSON: &str = "phi4_mini_json";
|
||||
pub const QWEN3_CODER: &str = "qwen3_coder";
|
||||
pub const QWEN3_XML: &str = "qwen3_xml";
|
||||
}
|
||||
@@ -71,6 +72,7 @@ impl ToolParserFactory {
|
||||
.register_parser::<Llama3JsonToolParser>(names::LLAMA4_JSON)
|
||||
.register_parser::<MinimaxM2ToolParser>(names::MINIMAX_M2)
|
||||
.register_parser::<MistralToolParser>(names::MISTRAL)
|
||||
.register_parser::<Phi4MiniJsonToolParser>(names::PHI4_MINI_JSON)
|
||||
.register_parser::<Qwen3XmlToolParser>(names::QWEN3_XML)
|
||||
.register_parser::<Qwen3CoderToolParser>(names::QWEN3_CODER);
|
||||
|
||||
|
||||
@@ -191,3 +191,14 @@ fn factory_new_resolves_default_patterns() {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_new_registers_phi4_mini_json_by_name() {
|
||||
// phi-4-mini is registered by explicit name only (matching Python's
|
||||
// `--tool-call-parser phi4_mini_json`); it is intentionally not mapped to
|
||||
// any model-name pattern.
|
||||
let factory = ToolParserFactory::new();
|
||||
|
||||
assert!(factory.contains(names::PHI4_MINI_JSON));
|
||||
factory.create(names::PHI4_MINI_JSON, &[]).unwrap();
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ pub use hermes::HermesToolParser;
|
||||
pub use internlm2::Internlm2ToolParser;
|
||||
pub use llama::Llama3JsonToolParser;
|
||||
pub use mistral::MistralToolParser;
|
||||
pub use phi4mini::Phi4MiniJsonToolParser;
|
||||
pub use qwen::Qwen3XmlToolParser;
|
||||
|
||||
mod hermes;
|
||||
mod internlm2;
|
||||
mod llama;
|
||||
mod mistral;
|
||||
mod phi4mini;
|
||||
mod qwen;
|
||||
|
||||
use winnow::ascii::multispace0 as ws0;
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace};
|
||||
use crate::{Result, Tool, ToolParser, ToolParserOutput};
|
||||
|
||||
const PHI4MINI_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
|
||||
parser_name: "Phi4Mini",
|
||||
start_marker: "functools[",
|
||||
end_marker: "]",
|
||||
marker_whitespace: JsonToolCallWhitespace::Optional,
|
||||
delimiter: Some(","),
|
||||
name_key: "name",
|
||||
// Accept both key variants emitted by Phi-4 Mini tool-call templates.
|
||||
arguments_key: &["arguments", "parameters"],
|
||||
};
|
||||
|
||||
/// Tool parser for phi-4-mini models.
|
||||
///
|
||||
/// Example tool-call content:
|
||||
///
|
||||
/// ```text
|
||||
/// functools[{"name": "get_weather", "arguments": {"location": "Tokyo"}}]
|
||||
/// ```
|
||||
///
|
||||
/// phi-4-mini emits an array of tool-call objects wrapped in a `functools[..]`
|
||||
/// envelope. Each object names the function with `name` and carries its
|
||||
/// arguments under `arguments` (preferred) or `parameters`. Arguments are
|
||||
/// already OpenAI-style JSON text, so they are streamed as raw argument deltas
|
||||
/// without schema conversion or JSON normalization.
|
||||
pub struct Phi4MiniJsonToolParser {
|
||||
inner: JsonToolCallParser,
|
||||
}
|
||||
|
||||
impl Phi4MiniJsonToolParser {
|
||||
/// Create a phi-4-mini tool parser.
|
||||
fn new(_tools: &[Tool]) -> Self {
|
||||
Self {
|
||||
inner: JsonToolCallParser::new(PHI4MINI_CONFIG),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolParser for Phi4MiniJsonToolParser {
|
||||
/// Create a boxed phi-4-mini tool parser.
|
||||
fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
|
||||
where
|
||||
Self: Sized + 'static,
|
||||
{
|
||||
Ok(Box::new(Self::new(tools)))
|
||||
}
|
||||
|
||||
/// Feed one decoded text chunk through the phi-4-mini parser.
|
||||
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
|
||||
self.inner.parse_into(chunk, output)
|
||||
}
|
||||
|
||||
/// Flush any buffered partial state at end of stream.
|
||||
fn finish(&mut self) -> Result<ToolParserOutput> {
|
||||
self.inner.finish()
|
||||
}
|
||||
|
||||
/// Clear parser state and return currently uncommitted buffered text.
|
||||
fn reset(&mut self) -> String {
|
||||
self.inner.reset()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use expect_test::expect;
|
||||
use thiserror_ext::AsReport;
|
||||
|
||||
use super::Phi4MiniJsonToolParser;
|
||||
use crate::test_utils::{collect_stream, split_by_chars, test_tools};
|
||||
use crate::{ToolParser, ToolParserTestExt as _};
|
||||
|
||||
/// Build one phi-4-mini tool-call object: `{"name":..,"<args_key>":<args>}`.
|
||||
fn build_call(function_name: &str, args_key: &str, arguments: &str) -> String {
|
||||
format!(r#"{{"name":"{function_name}","{args_key}":{arguments}}}"#)
|
||||
}
|
||||
|
||||
/// Wrap tool-call objects in the `functools[..]` envelope.
|
||||
fn wrap(calls: &[String]) -> String {
|
||||
format!("functools[{}]", calls.join(","))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phi4mini_parse_complete_without_tool_call_keeps_text() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let result = parser.parse_complete("Hello, world!").unwrap();
|
||||
|
||||
assert_eq!(result.normal_text, "Hello, world!");
|
||||
assert!(result.calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phi4mini_parse_complete_extracts_arguments_key() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let arguments = r#"{"location":"Tokyo","days":"3"}"#;
|
||||
let result = parser
|
||||
.parse_complete(&wrap(&[build_call("get_weather", "arguments", arguments)]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.calls.len(), 1);
|
||||
assert_eq!(result.calls[0].tool_index, 0);
|
||||
assert_eq!(result.calls[0].name.as_deref(), Some("get_weather"));
|
||||
assert_eq!(result.calls[0].arguments, arguments);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phi4mini_parse_complete_falls_back_to_parameters_key() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let arguments = r#"{"location":"Tokyo"}"#;
|
||||
let result = parser
|
||||
.parse_complete(&wrap(&[build_call("get_weather", "parameters", arguments)]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.calls.len(), 1);
|
||||
assert_eq!(result.calls[0].name.as_deref(), Some("get_weather"));
|
||||
assert_eq!(result.calls[0].arguments, arguments);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phi4mini_extracts_multiple_comma_delimited_calls() {
|
||||
let input = wrap(&[
|
||||
build_call("get_weather", "arguments", r#"{"location":"Shanghai"}"#),
|
||||
build_call("add", "arguments", r#"{"x":1,"y":2}"#),
|
||||
]);
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
|
||||
let result = parser.parse_complete(&input).unwrap();
|
||||
|
||||
expect![[r#"
|
||||
ToolParserOutput {
|
||||
normal_text: "",
|
||||
calls: [
|
||||
ToolCallDelta {
|
||||
tool_index: 0,
|
||||
name: Some(
|
||||
"get_weather",
|
||||
),
|
||||
arguments: "{\"location\":\"Shanghai\"}",
|
||||
},
|
||||
ToolCallDelta {
|
||||
tool_index: 1,
|
||||
name: Some(
|
||||
"add",
|
||||
),
|
||||
arguments: "{\"x\":1,\"y\":2}",
|
||||
},
|
||||
],
|
||||
}
|
||||
"#]]
|
||||
.assert_debug_eq(&result);
|
||||
}
|
||||
|
||||
/// The shared JSON core scans matched braces, so bracket-bearing argument
|
||||
/// values are forwarded intact.
|
||||
#[test]
|
||||
fn phi4mini_array_valued_arguments_are_not_truncated() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let arguments = r#"{"items":[1,2],"flag":true}"#;
|
||||
let result = parser
|
||||
.parse_complete(&wrap(&[build_call("convert", "arguments", arguments)]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.calls.len(), 1);
|
||||
assert_eq!(result.calls[0].arguments, arguments);
|
||||
}
|
||||
|
||||
/// Preface text before a tool call is preserved as normal_text, consistent
|
||||
/// with the other JSON parsers in this crate.
|
||||
#[test]
|
||||
fn phi4mini_preserves_text_before_tool_call() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let input = format!(
|
||||
"Let me check.\n{}",
|
||||
wrap(&[build_call(
|
||||
"get_weather",
|
||||
"arguments",
|
||||
r#"{"location":"Tokyo"}"#
|
||||
)])
|
||||
);
|
||||
|
||||
let result = parser.parse_complete(&input).unwrap();
|
||||
|
||||
assert_eq!(result.normal_text, "Let me check.\n");
|
||||
assert_eq!(result.calls.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phi4mini_does_not_validate_or_normalize_arguments() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let arguments = r#"{"location":"Tokyo",}"#;
|
||||
let result = parser
|
||||
.parse_complete(&wrap(&[build_call("get_weather", "arguments", arguments)]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.calls[0].arguments, arguments);
|
||||
}
|
||||
|
||||
/// The bundled `tool_chat_template_phi4_mini.jinja` emits objects with
|
||||
/// whitespace after `:` and `,` (e.g. `{"name": "f", "arguments": {..}}`).
|
||||
/// Confirm the parser handles that real model format and preserves the
|
||||
/// inner argument spacing verbatim.
|
||||
#[test]
|
||||
fn phi4mini_accepts_real_model_whitespace_format() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let input = r#"functools[{"name": "get_weather", "arguments": {"location": "Tokyo"}}]"#;
|
||||
|
||||
let result = parser.parse_complete(input).unwrap();
|
||||
|
||||
assert_eq!(result.calls.len(), 1);
|
||||
assert_eq!(result.calls[0].name.as_deref(), Some("get_weather"));
|
||||
assert_eq!(result.calls[0].arguments, r#"{"location": "Tokyo"}"#);
|
||||
}
|
||||
|
||||
/// Argument deltas are streamed through the shared JSON core.
|
||||
#[test]
|
||||
fn phi4mini_streaming_emits_argument_deltas() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let chunks = [
|
||||
"preface functo",
|
||||
"ols[",
|
||||
r#"{"name":"get_weather","arguments":"#,
|
||||
r#"{"location":"#,
|
||||
r#""Beijing""#,
|
||||
r#"}"#,
|
||||
r#"}]"#,
|
||||
" suffix",
|
||||
];
|
||||
|
||||
let result = collect_stream(&mut parser, &chunks);
|
||||
|
||||
assert_eq!(result.normal_text, "preface suffix");
|
||||
assert_eq!(result.calls.len(), 1);
|
||||
assert_eq!(result.calls[0].name.as_deref(), Some("get_weather"));
|
||||
assert_eq!(result.calls[0].arguments, r#"{"location":"Beijing"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phi4mini_streaming_handles_split_markers() {
|
||||
let input = format!(
|
||||
"hello {}",
|
||||
wrap(&[build_call(
|
||||
"get_weather",
|
||||
"arguments",
|
||||
r#"{"location":"Tokyo"}"#
|
||||
)])
|
||||
);
|
||||
let chunks = split_by_chars(&input, 5);
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
|
||||
let result = collect_stream(&mut parser, &chunks);
|
||||
|
||||
assert_eq!(result.normal_text, "hello ");
|
||||
assert_eq!(result.calls.len(), 1);
|
||||
assert_eq!(result.calls[0].arguments, r#"{"location":"Tokyo"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phi4mini_finish_errors_on_truncated_tool_call() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let _ = parser
|
||||
.parse_chunk(r#"functools[{"name":"get_weather","arguments":{"location""#)
|
||||
.unwrap();
|
||||
let error = parser.finish().unwrap_err();
|
||||
|
||||
assert!(
|
||||
error.to_report_string().contains("incomplete Phi4Mini tool call"),
|
||||
"finish() reports the truncated tool call as incomplete: {}",
|
||||
error.to_report_string(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phi4mini_preserve_special_tokens_is_false() {
|
||||
let parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
assert!(!parser.preserve_special_tokens());
|
||||
}
|
||||
|
||||
/// The brace-scanning core handles nested arrays and objects in arguments.
|
||||
#[test]
|
||||
fn phi4mini_parses_nested_arrays_and_objects() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let arguments =
|
||||
r#"{"array_field":["a","b","c"],"object_field":{"nested":"value"},"empty_object":{}}"#;
|
||||
let result = parser
|
||||
.parse_complete(&wrap(&[build_call("convert", "arguments", arguments)]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.calls.len(), 1);
|
||||
assert_eq!(result.calls[0].name.as_deref(), Some("convert"));
|
||||
assert_eq!(result.calls[0].arguments, arguments);
|
||||
}
|
||||
|
||||
/// The chat template emits parallel calls as `},\n {` (comma + newline +
|
||||
/// indent). Confirm the `Optional` marker whitespace and `,` delimiter
|
||||
/// parse the real multi-call layout.
|
||||
#[test]
|
||||
fn phi4mini_parses_parallel_calls_in_template_format() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let input = concat!(
|
||||
"functools[\n",
|
||||
" {\"name\": \"get_weather\", \"arguments\": {\"city\": \"Tokyo\"}},\n",
|
||||
" {\"name\": \"add\", \"arguments\": {\"x\": 1, \"y\": 2}}\n",
|
||||
"]"
|
||||
);
|
||||
|
||||
let result = parser.parse_complete(input).unwrap();
|
||||
|
||||
assert_eq!(result.calls.len(), 2);
|
||||
assert_eq!(result.calls[0].name.as_deref(), Some("get_weather"));
|
||||
assert_eq!(result.calls[1].name.as_deref(), Some("add"));
|
||||
}
|
||||
|
||||
/// The shared core requires an object after the start marker.
|
||||
#[test]
|
||||
fn phi4mini_empty_array_errors() {
|
||||
let mut parser = Phi4MiniJsonToolParser::new(&test_tools());
|
||||
let error = parser.parse_complete("functools[]").unwrap_err();
|
||||
|
||||
assert!(
|
||||
error.to_report_string().contains("invalid Phi4Mini"),
|
||||
"empty functools[] should error: {}",
|
||||
error.to_report_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser};
|
||||
pub use hy_v3::HyV3ToolParser;
|
||||
pub use json::{
|
||||
HermesToolParser, Internlm2ToolParser, Llama3JsonToolParser, MistralToolParser,
|
||||
Qwen3XmlToolParser,
|
||||
Phi4MiniJsonToolParser, Qwen3XmlToolParser,
|
||||
};
|
||||
pub use kimi_k2::KimiK2ToolParser;
|
||||
pub use minimax_m2::MinimaxM2ToolParser;
|
||||
|
||||
Reference in New Issue
Block a user