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;