From c8beda4cc38b1b34c172dc149b31b4e39dc2b569 Mon Sep 17 00:00:00 2001 From: Devin Lai <161107414+devin-lai@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:40:00 +0800 Subject: [PATCH] [Rust Frontend] Add Phi-4 mini JSON tool parser (#44213) --- rust/src/chat/src/lib.rs | 2 +- rust/src/chat/src/parser/tool/mod.rs | 6 +- rust/src/chat/src/parser/tool/tests.rs | 11 + rust/src/tool-parser/src/json/mod.rs | 2 + rust/src/tool-parser/src/json/phi4mini.rs | 327 ++++++++++++++++++++++ rust/src/tool-parser/src/lib.rs | 2 +- 6 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 rust/src/tool-parser/src/json/phi4mini.rs diff --git a/rust/src/chat/src/lib.rs b/rust/src/chat/src/lib.rs index 5b6f66cf417..e0bf9652158 100644 --- a/rust/src/chat/src/lib.rs +++ b/rust/src/chat/src/lib.rs @@ -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] diff --git a/rust/src/chat/src/parser/tool/mod.rs b/rust/src/chat/src/parser/tool/mod.rs index ad220b5a787..29961d1d82a 100644 --- a/rust/src/chat/src/parser/tool/mod.rs +++ b/rust/src/chat/src/parser/tool/mod.rs @@ -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::(names::LLAMA4_JSON) .register_parser::(names::MINIMAX_M2) .register_parser::(names::MISTRAL) + .register_parser::(names::PHI4_MINI_JSON) .register_parser::(names::QWEN3_XML) .register_parser::(names::QWEN3_CODER); diff --git a/rust/src/chat/src/parser/tool/tests.rs b/rust/src/chat/src/parser/tool/tests.rs index 65e9f4e075b..6fd380bd223 100644 --- a/rust/src/chat/src/parser/tool/tests.rs +++ b/rust/src/chat/src/parser/tool/tests.rs @@ -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(); +} diff --git a/rust/src/tool-parser/src/json/mod.rs b/rust/src/tool-parser/src/json/mod.rs index 5102c025c2c..9cc1d2ed543 100644 --- a/rust/src/tool-parser/src/json/mod.rs +++ b/rust/src/tool-parser/src/json/mod.rs @@ -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; diff --git a/rust/src/tool-parser/src/json/phi4mini.rs b/rust/src/tool-parser/src/json/phi4mini.rs new file mode 100644 index 00000000000..463354b13c9 --- /dev/null +++ b/rust/src/tool-parser/src/json/phi4mini.rs @@ -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> + 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 { + 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":..,"":}`. + 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(), + ); + } +} diff --git a/rust/src/tool-parser/src/lib.rs b/rust/src/tool-parser/src/lib.rs index f1dc0455843..5f9d6daaf4e 100644 --- a/rust/src/tool-parser/src/lib.rs +++ b/rust/src/tool-parser/src/lib.rs @@ -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;