@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 5af4aec141
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[Rust Frontend] Add standalone `granite4` tool parser (#45216)

Signed-off-by: Tahsin Tunan <tahsintunan@gmail.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Tahsin Tunan committed Jun 12, 2026 at 22:16 UTC 5af4aec141cb1047b90e17f069974f99135cd48a
6 files changed +511 -7
rust/src/chat/src/lib.rs
+1 -1
@@ -271,7 +271,7 @@ mod tests {
271 )
272 .unwrap_err();
273
274 - 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());
274 + 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, granite4, 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());
275 }
276
277 #[test]
rust/src/chat/src/parser/tool/mod.rs
+7 -4
@@ -4,10 +4,10 @@ use std::sync::LazyLock;
4
5 pub use vllm_tool_parser::{
6 DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
7 - Gemma4ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, HermesToolParser, HyV3ToolParser,
8 - Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser,
9 - MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser, Qwen3XmlToolParser,
10 - ToolCallDelta, ToolParser, ToolParserError, ToolParserOutput,
7 + Gemma4ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser,
8 + HyV3ToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser,
9 + MinimaxM2ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser,
10 + Qwen3XmlToolParser, ToolCallDelta, ToolParser, ToolParserError, ToolParserOutput,
11 };
12
13 use crate::parser::ParserFactory;
@@ -22,6 +22,7 @@ pub mod names {
22 pub const GLM45: &str = "glm45";
23 pub const GLM47: &str = "glm47";
24 pub const GEMMA4: &str = "gemma4";
25 + pub const GRANITE4: &str = "granite4";
26 pub const HERMES: &str = "hermes";
27 pub const HY_V3: &str = "hy_v3";
28 // Matches the Python CLI name `--tool-call-parser internlm`, which Python
@@ -64,6 +65,7 @@ impl ToolParserFactory {
65 .register_parser::<Glm45MoeToolParser>(names::GLM45)
66 .register_parser::<Glm47MoeToolParser>(names::GLM47)
67 .register_parser::<Gemma4ToolParser>(names::GEMMA4)
68 + .register_parser::<Granite4ToolParser>(names::GRANITE4)
69 .register_parser::<HermesToolParser>(names::HERMES)
70 .register_parser::<HyV3ToolParser>(names::HY_V3)
71 .register_parser::<Internlm2ToolParser>(names::INTERNLM)
@@ -107,6 +109,7 @@ impl ToolParserFactory {
109 .register_pattern("glm-4.5", names::GLM45)
110 .register_pattern("gemma4", names::GEMMA4)
111 .register_pattern("gemma-4", names::GEMMA4)
112 + .register_pattern("granite-4", names::GRANITE4)
113 .register_pattern("kimi-k2", names::KIMI_K2)
114 .register_pattern("minimax", names::MINIMAX_M2)
115 .register_pattern("mm-m2", names::MINIMAX_M2);
rust/src/chat/src/parser/tool/tests.rs
+4
@@ -145,6 +145,10 @@ fn factory_new_resolves_default_patterns() {
145 factory.resolve_name_for_model("google/gemma-4-27b-it"),
146 Some(names::GEMMA4)
147 );
148 + assert_eq!(
149 + factory.resolve_name_for_model("ibm-granite/granite-4.0-h-tiny"),
150 + Some(names::GRANITE4)
151 + );
152 assert_eq!(
153 factory.resolve_name_for_model("NousResearch/Hermes-3-Llama-3.1-8B"),
154 Some(names::HERMES)
rust/src/tool-parser/src/json/granite4.rs new
+495
@@ -0,0 +1,495 @@
1 +use winnow::ascii::multispace0 as ws0;
2 +use winnow::combinator::{alt, peek, seq};
3 +use winnow::error::{ContextError, ErrMode, ModalResult, StrContext};
4 +use winnow::prelude::*;
5 +use winnow::token::{any, literal};
6 +
7 +use super::{
8 + JsonToolCallConfig, JsonToolCallEvent, JsonToolCallWhitespace, JsonToolInput,
9 + tool_call_header_event,
10 +};
11 +use crate::utils::{
12 + JsonObjectScanState, json_str, parse_buffered_event, safe_text_len, take_json_object,
13 +};
14 +use crate::{Result, Tool, ToolCallDelta, ToolParser, ToolParserOutput};
15 +
16 +const TOOL_CALL_START: &str = "<tool_call>";
17 +const TOOL_CALL_END: &str = "</tool_call>";
18 +
19 +#[derive(Debug, Clone, PartialEq, Eq)]
20 +enum Granite4Mode {
21 + Text,
22 + Header,
23 + /// Parsing the arguments value:
24 + /// `None` until the first byte decides object vs string;
25 + /// `Some` while streaming an object value.
26 + Args {
27 + json_scan: Option<JsonObjectScanState>,
28 + },
29 + /// Arguments done; consume the object's closing `}` and `</tool_call>`.
30 + Close,
31 +}
32 +
33 +#[derive(Debug, Clone, PartialEq, Eq)]
34 +enum Granite4Event {
35 + Text {
36 + len: usize,
37 + },
38 + ToolCallStart,
39 + ToolCallHeader {
40 + function_name: String,
41 + },
42 + /// Verbatim bytes of an object-valued arguments payload; `complete` once the
43 + /// object scan reaches its closing brace.
44 + ObjectArgsDelta {
45 + len: usize,
46 + complete: bool,
47 + },
48 + /// Decoded contents of a string-valued arguments payload.
49 + StringArgs {
50 + decoded: String,
51 + },
52 + ToolCallEnd,
53 +}
54 +
55 +/// Tool parser for Granite 4 `<tool_call>…</tool_call>` JSON tool calls.
56 +///
57 +/// Example tool call content:
58 +///
59 +/// ```text
60 +/// <tool_call>{"name": "get_weather", "arguments": {"city": "Boston"}}</tool_call>
61 +/// ```
62 +///
63 +/// Parallel calls are repeated `<tool_call>…</tool_call>` blocks with ordinary
64 +/// content interleaved between them. This reuses the shared JSON helpers for
65 +/// everything except one Granite 4 specific step (`args_event`): the `arguments`
66 +/// value may be a JSON object (kept verbatim) **or** a JSON string whose decoded
67 +/// contents are the arguments (the `# test granite behavior` case in Python).
68 +pub struct Granite4ToolParser {
69 + buffer: String,
70 + mode: Granite4Mode,
71 + active_tool_index: Option<usize>,
72 + emitted_tool_count: usize,
73 +}
74 +
75 +impl Granite4ToolParser {
76 + /// Create a Granite 4 tool parser.
77 + fn new(_tools: &[Tool]) -> Self {
78 + Self {
79 + buffer: String::new(),
80 + mode: Granite4Mode::Text,
81 + active_tool_index: None,
82 + emitted_tool_count: 0,
83 + }
84 + }
85 +
86 + /// Apply one parsed Granite 4 event to parser state and output.
87 + fn apply_event(&mut self, event: Granite4Event, output: &mut ToolParserOutput) -> Result<()> {
88 + match event {
89 + Granite4Event::Text { len } => output.normal_text.push_str(&self.buffer[..len]),
90 + Granite4Event::ToolCallStart => self.mode = Granite4Mode::Header,
91 + Granite4Event::ToolCallHeader { function_name } => {
92 + let tool_index = self.emitted_tool_count;
93 + self.emitted_tool_count += 1;
94 + self.active_tool_index = Some(tool_index);
95 + self.mode = Granite4Mode::Args { json_scan: None };
96 + output.calls.push(ToolCallDelta {
97 + tool_index,
98 + name: Some(function_name),
99 + arguments: String::new(),
100 + });
101 + }
102 + Granite4Event::ObjectArgsDelta { len, complete } => {
103 + let arguments = self.buffer[..len].to_string();
104 + self.push_arguments(arguments, output)?;
105 + if complete {
106 + self.mode = Granite4Mode::Close;
107 + }
108 + }
109 + Granite4Event::StringArgs { decoded } => {
110 + self.push_arguments(decoded, output)?;
111 + self.mode = Granite4Mode::Close;
112 + }
113 + Granite4Event::ToolCallEnd => {
114 + self.active_tool_index = None;
115 + self.mode = Granite4Mode::Text;
116 + }
117 + }
118 + Ok(())
119 + }
120 +
121 + /// Append one arguments delta to the active tool call.
122 + fn push_arguments(&self, arguments: String, output: &mut ToolParserOutput) -> Result<()> {
123 + let Some(tool_index) = self.active_tool_index else {
124 + return Err(parsing_failed!(
125 + "Granite4 arguments without an active tool call"
126 + ));
127 + };
128 + output.calls.push(ToolCallDelta {
129 + tool_index,
130 + name: None,
131 + arguments,
132 + });
133 + Ok(())
134 + }
135 +
136 + fn reset(&mut self) -> String {
137 + self.mode = Granite4Mode::Text;
138 + self.active_tool_index = None;
139 + self.emitted_tool_count = 0;
140 + std::mem::take(&mut self.buffer)
141 + }
142 +}
143 +
144 +impl ToolParser for Granite4ToolParser {
145 + fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
146 + where
147 + Self: Sized + 'static,
148 + {
149 + Ok(Box::new(Self::new(tools)))
150 + }
151 +
152 + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
153 + self.buffer.push_str(chunk);
154 +
155 + while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
156 + parse_next_granite4_event(input, &mut self.mode)
157 + })? {
158 + self.apply_event(event, output)?;
159 + self.buffer.drain(..consumed_len);
160 + }
161 +
162 + Ok(())
163 + }
164 +
165 + fn finish(&mut self) -> Result<ToolParserOutput> {
166 + let mut output = ToolParserOutput::default();
167 + match &self.mode {
168 + Granite4Mode::Text => output.normal_text.push_str(&self.buffer),
169 + Granite4Mode::Header | Granite4Mode::Args { .. } | Granite4Mode::Close => {
170 + return Err(parsing_failed!("incomplete Granite4 tool call"));
171 + }
172 + }
173 + let _ = self.reset();
174 + Ok(output)
175 + }
176 +
177 + fn reset(&mut self) -> String {
178 + Granite4ToolParser::reset(self)
179 + }
180 +}
181 +
182 +/// Parse a Granite 4 event for the current parser mode.
183 +fn parse_next_granite4_event(
184 + input: &mut JsonToolInput<'_>,
185 + mode: &mut Granite4Mode,
186 +) -> ModalResult<Granite4Event> {
187 + match mode {
188 + Granite4Mode::Text => text_event(input),
189 + Granite4Mode::Header => header_event(input),
190 + Granite4Mode::Args { json_scan } => args_event(input, json_scan),
191 + Granite4Mode::Close => close_event(input),
192 + }
193 +}
194 +
195 +/// Parse content text or the start of a `<tool_call>` block. *(reuses `safe_text_len`)*
196 +fn text_event(input: &mut JsonToolInput<'_>) -> ModalResult<Granite4Event> {
197 + alt((
198 + |input: &mut JsonToolInput<'_>| {
199 + seq!(_: literal(TOOL_CALL_START), _: ws0)
200 + .value(Granite4Event::ToolCallStart)
201 + .parse_next(input)
202 + },
203 + |input: &mut JsonToolInput<'_>| {
204 + safe_text_len(input, TOOL_CALL_START).map(|len| Granite4Event::Text { len })
205 + },
206 + ))
207 + .parse_next(input)
208 +}
209 +
210 +/// Parse the `{"name":"X","arguments":` header before the value. *(reuses `tool_call_header_event`)*
211 +fn header_event(input: &mut JsonToolInput<'_>) -> ModalResult<Granite4Event> {
212 + const CONFIG: JsonToolCallConfig = JsonToolCallConfig {
213 + parser_name: "Granite4",
214 + start_marker: "",
215 + end_marker: "",
216 + marker_whitespace: JsonToolCallWhitespace::Optional,
217 + delimiter: None,
218 + name_key: "name",
219 + arguments_key: &["arguments"],
220 + };
221 +
222 + match tool_call_header_event(input, CONFIG)? {
223 + JsonToolCallEvent::ToolCallHeader { function_name } => {
224 + Ok(Granite4Event::ToolCallHeader { function_name })
225 + }
226 + _ => unreachable!("tool_call_header_event only emits ToolCallHeader"),
227 + }
228 +}
229 +
230 +/// Parse one arguments-value event.
231 +///
232 +/// GRANITE 4 SPECIFIC - the sole behavior that differs from the shared
233 +/// `<tool_call>` JSON parsers. The value is either a JSON object (kept verbatim,
234 +/// streamed incrementally via `take_json_object`) or an escaped JSON string
235 +/// (decoded whole via `json_str`). The string form is why we cannot just forward
236 +/// raw arg bytes like the sibling parsers do: an escaped string only resolves
237 +/// once seen whole and unescaped.
238 +fn args_event(
239 + input: &mut JsonToolInput<'_>,
240 + json_scan: &mut Option<JsonObjectScanState>,
241 +) -> ModalResult<Granite4Event> {
242 + if let Some(scan) = json_scan {
243 + let len = take_json_object(input, scan)?;
244 + return Ok(Granite4Event::ObjectArgsDelta {
245 + len,
246 + complete: scan.complete(),
247 + });
248 + }
249 +
250 + match peek(any).parse_next(input)? {
251 + '{' => {
252 + let mut scan = JsonObjectScanState::default();
253 + let len = take_json_object(input, &mut scan)?;
254 + let complete = scan.complete();
255 + *json_scan = Some(scan);
256 + Ok(Granite4Event::ObjectArgsDelta { len, complete })
257 + }
258 + '"' => Ok(Granite4Event::StringArgs {
259 + decoded: json_str(input)?,
260 + }),
261 + _ => {
262 + let mut error = ContextError::new();
263 + error.push(StrContext::Label("Granite4 arguments"));
264 + Err(ErrMode::Cut(error))
265 + }
266 + }
267 +}
268 +
269 +/// Parse the tool-call object's closing `}` and the `</tool_call>` end marker.
270 +fn close_event(input: &mut JsonToolInput<'_>) -> ModalResult<Granite4Event> {
271 + seq!(_: ws0, _: literal("}"), _: ws0, _: literal(TOOL_CALL_END))
272 + .value(Granite4Event::ToolCallEnd)
273 + .parse_next(input)
274 +}
275 +
276 +#[cfg(test)]
277 +mod tests {
278 + use expect_test::expect;
279 + use thiserror_ext::AsReport;
280 +
281 + use super::Granite4ToolParser;
282 + use crate::test_utils::{collect_stream, split_by_chars, test_tools};
283 + use crate::{ToolParser, ToolParserOutput, ToolParserTestExt as _};
284 +
285 + #[test]
286 + fn granite4_parse_complete_without_tool_call_keeps_text() {
287 + let mut parser = Granite4ToolParser::new(&test_tools());
288 + let output = parser.parse_complete("Hello, world!").unwrap();
289 +
290 + assert_eq!(output.normal_text, "Hello, world!");
291 + assert!(output.calls.is_empty());
292 + }
293 +
294 + #[test]
295 + fn granite4_parse_complete_object_args() {
296 + let mut parser = Granite4ToolParser::new(&test_tools());
297 + let output = parser
298 + .parse_complete(
299 + r#"<tool_call>{"name":"get_weather","arguments":{"city":"Boston"}}</tool_call>"#,
300 + )
301 + .unwrap();
302 +
303 + assert_eq!(output.normal_text, "");
304 + assert_eq!(output.calls.len(), 1);
305 + assert_eq!(output.calls[0].name.as_deref(), Some("get_weather"));
306 + assert_eq!(output.calls[0].arguments, r#"{"city":"Boston"}"#);
307 + }
308 +
309 + #[test]
310 + fn granite4_parse_complete_string_args() {
311 + // GRANITE4-SPECIFIC: `arguments` may be a pre-serialized JSON string; its
312 + // decoded contents become the arguments.
313 + let mut parser = Granite4ToolParser::new(&test_tools());
314 + let output = parser
315 + .parse_complete(
316 + r#"<tool_call>{"name":"get_weather","arguments":"{\"city\":\"Boston\"}"}</tool_call>"#,
317 + )
318 + .unwrap();
319 +
320 + assert_eq!(output.calls.len(), 1);
321 + assert_eq!(output.calls[0].name.as_deref(), Some("get_weather"));
322 + assert_eq!(output.calls[0].arguments, r#"{"city":"Boston"}"#);
323 + }
324 +
325 + #[test]
326 + fn granite4_extracts_interleaved_content_and_mixed_args() {
327 + let mut parser = Granite4ToolParser::new(&test_tools());
328 + let output = parser
329 + .parse_complete(
330 + r#"before <tool_call>{"name":"find_bbox","arguments":"{\"x\":1}"}</tool_call> middle <tool_call>{"name":"get_weather","arguments":{"city":"Boston"}}</tool_call> after"#,
331 + )
332 + .unwrap();
333 +
334 + expect![[r#"
335 + ToolParserOutput {
336 + normal_text: "before middle after",
337 + calls: [
338 + ToolCallDelta {
339 + tool_index: 0,
340 + name: Some(
341 + "find_bbox",
342 + ),
343 + arguments: "{\"x\":1}",
344 + },
345 + ToolCallDelta {
346 + tool_index: 1,
347 + name: Some(
348 + "get_weather",
349 + ),
350 + arguments: "{\"city\":\"Boston\"}",
351 + },
352 + ],
353 + }
354 + "#]]
355 + .assert_debug_eq(&output);
356 + }
357 +
358 + #[test]
359 + fn granite4_streaming_handles_split_markers() {
360 + let input = r#"hello <tool_call>{"name":"get_weather","arguments":{"city":"Tokyo"}}</tool_call> bye"#;
361 + let chunks = split_by_chars(input, 5);
362 + let mut parser = Granite4ToolParser::new(&test_tools());
363 +
364 + let output = collect_stream(&mut parser, &chunks);
365 +
366 + assert_eq!(output.normal_text, "hello bye");
367 + assert_eq!(output.calls.len(), 1);
368 + assert_eq!(output.calls[0].name.as_deref(), Some("get_weather"));
369 + assert_eq!(output.calls[0].arguments, r#"{"city":"Tokyo"}"#);
370 + }
371 +
372 + #[test]
373 + fn granite4_streaming_emits_object_argument_deltas() {
374 + let mut parser = Granite4ToolParser::new(&test_tools());
375 + let chunks = [
376 + r#"<tool_call>{"name":"get_weather","arguments":"#,
377 + r#"{"city":"#,
378 + r#""Beijing""#,
379 + r#"}"#,
380 + r#"}</tool_call>"#,
381 + ];
382 +
383 + let mut output = ToolParserOutput::default();
384 + let mut observed_arguments = Vec::new();
385 + for chunk in chunks {
386 + let next = parser.parse_chunk(chunk).unwrap();
387 + observed_arguments.extend(
388 + next.calls
389 + .iter()
390 + .filter(|call| call.name.is_none())
391 + .map(|call| call.arguments.clone()),
392 + );
393 + output.append(next);
394 + }
395 + output.append(parser.finish().unwrap());
396 +
397 + assert_eq!(observed_arguments, [r#"{"city":"#, r#""Beijing""#, r#"}"#]);
398 + assert_eq!(
399 + output.coalesce_calls().calls[0].arguments,
400 + r#"{"city":"Beijing"}"#
401 + );
402 + }
403 +
404 + #[test]
405 + fn granite4_string_args_split_across_chunks() {
406 + let input = r#"<tool_call>{"name":"f","arguments":"{\"a\":1}"}</tool_call>"#;
407 + let chunks = split_by_chars(input, 3);
408 + let mut parser = Granite4ToolParser::new(&test_tools());
409 +
410 + let output = collect_stream(&mut parser, &chunks);
411 +
412 + assert_eq!(output.calls.len(), 1);
413 + assert_eq!(output.calls[0].name.as_deref(), Some("f"));
414 + assert_eq!(output.calls[0].arguments, r#"{"a":1}"#);
415 + }
416 +
417 + #[test]
418 + fn granite4_streaming_handles_marker_and_json_whitespace() {
419 + // Granite spaces the markers (`<tool_call> {…} </tool_call>`) and the JSON
420 + // (`"name": …`). Since `args_event` has no leading `ws0`, this guards that
421 + // the header consumes the whitespace before the arguments value.
422 + let input = concat!(
423 + "Here goes the bbox call: \n",
424 + r#"<tool_call> {"name": "find_bbox", "arguments": "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}"} </tool_call>"#,
425 + " Now the stock price call: \n ",
426 + r#"<tool_call> {"name": "get_stock_price", "arguments": {"symbol": "AAPL", "start_date": "2021-01-01", "end_date": "2021-12-31"}} </tool_call>"#,
427 + " Now another bbox call: \n ",
428 + r#"<tool_call> {"name": "find_bbox", "arguments": "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}"} </tool_call>"#,
429 + " See? I'm a helpful assistant.",
430 + );
431 + let chunks = split_by_chars(input, 3);
432 + let mut parser = Granite4ToolParser::new(&test_tools());
433 +
434 + let output = collect_stream(&mut parser, &chunks);
435 +
436 + expect![[r#"
437 + ToolParserOutput {
438 + normal_text: "Here goes the bbox call: \n Now the stock price call: \n Now another bbox call: \n See? I'm a helpful assistant.",
439 + calls: [
440 + ToolCallDelta {
441 + tool_index: 0,
442 + name: Some(
443 + "find_bbox",
444 + ),
445 + arguments: "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}",
446 + },
447 + ToolCallDelta {
448 + tool_index: 1,
449 + name: Some(
450 + "get_stock_price",
451 + ),
452 + arguments: "{\"symbol\": \"AAPL\", \"start_date\": \"2021-01-01\", \"end_date\": \"2021-12-31\"}",
453 + },
454 + ToolCallDelta {
455 + tool_index: 2,
456 + name: Some(
457 + "find_bbox",
458 + ),
459 + arguments: "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}",
460 + },
461 + ],
462 + }
463 + "#]].assert_debug_eq(&output);
464 + }
465 +
466 + #[test]
467 + fn granite4_finish_fails_incomplete_tool_call() {
468 + let mut parser = Granite4ToolParser::new(&test_tools());
469 + parser
470 + .parse_chunk(r#"<tool_call>{"name":"get_weather","arguments":{"city""#)
471 + .unwrap();
472 +
473 + let error = parser.finish().unwrap_err();
474 +
475 + expect!["tool parser parsing failed: incomplete Granite4 tool call"]
476 + .assert_eq(&error.to_report_string());
477 + }
478 +
479 + #[test]
480 + fn granite4_rejects_non_object_non_string_args() {
481 + let mut parser = Granite4ToolParser::new(&test_tools());
482 + let error = parser
483 + .parse_chunk(r#"<tool_call>{"name":"f","arguments":42}</tool_call>"#)
484 + .unwrap_err();
485 +
486 + expect!["tool parser parsing failed: invalid Granite4 arguments"]
487 + .assert_eq(&error.to_report_string());
488 + }
489 +
490 + #[test]
491 + fn granite4_preserve_special_tokens_is_false() {
492 + let parser = Granite4ToolParser::new(&test_tools());
493 + assert!(!parser.preserve_special_tokens());
494 + }
495 +}
rust/src/tool-parser/src/json/mod.rs
+2
@@ -1,5 +1,6 @@
1 //! Shared parser core for JSON tool calls wrapped by text markers.
2
3 +pub use granite4::Granite4ToolParser;
4 pub use hermes::HermesToolParser;
5 pub use internlm2::Internlm2ToolParser;
6 pub use llama::Llama3JsonToolParser;
@@ -7,6 +8,7 @@ pub use mistral::MistralToolParser;
8 pub use phi4mini::Phi4MiniJsonToolParser;
9 pub use qwen::Qwen3XmlToolParser;
10
11 +mod granite4;
12 mod hermes;
13 mod internlm2;
14 mod llama;
rust/src/tool-parser/src/lib.rs
+2 -2
@@ -25,8 +25,8 @@ pub use gemma4::Gemma4ToolParser;
25 pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser};
26 pub use hy_v3::HyV3ToolParser;
27 pub use json::{
28 - HermesToolParser, Internlm2ToolParser, Llama3JsonToolParser, MistralToolParser,
29 - Phi4MiniJsonToolParser, Qwen3XmlToolParser,
28 + Granite4ToolParser, HermesToolParser, Internlm2ToolParser, Llama3JsonToolParser,
29 + MistralToolParser, Phi4MiniJsonToolParser, Qwen3XmlToolParser,
30 };
31 pub use kimi_k2::KimiK2ToolParser;
32 pub use minimax_m2::MinimaxM2ToolParser;