1use llama_cpp_bindings_types::PairedQuoteShape;
2use llama_cpp_bindings_types::ParsedToolCall;
3use llama_cpp_bindings_types::ToolCallArguments;
4use llama_cpp_bindings_types::ToolCallMarkers;
5use llama_cpp_bindings_types::ToolCallValueQuote;
6
7use crate::error::PairedQuoteFailure;
8
9enum ParseStep<'body> {
10 Done,
11 Call(ParsedToolCall, &'body str),
12}
13
14fn consume_optional_prefix<'body>(input: &'body str, literal: &str) -> &'body str {
15 input.strip_prefix(literal).unwrap_or(input)
16}
17
18fn split_at_separator<'body>(
19 input: &'body str,
20 separator: &str,
21) -> Option<(&'body str, &'body str)> {
22 let (name_raw, after_separator) = input.split_once(separator)?;
23 Some((name_raw, after_separator))
24}
25
26fn bare_value_to_json(text: &str) -> serde_json::Value {
27 if text.is_empty() {
28 return serde_json::Value::Null;
29 }
30 serde_json::from_str::<serde_json::Value>(text)
31 .ok()
32 .unwrap_or_else(|| serde_json::Value::String(text.to_owned()))
33}
34
35fn find_bare_value_end(input: &str, close_marker: &str) -> usize {
36 for (byte_index, character) in input.char_indices() {
37 if character == ',' {
38 return byte_index;
39 }
40 if !close_marker.is_empty() && input[byte_index..].starts_with(close_marker) {
41 return byte_index;
42 }
43 }
44
45 input.len()
46}
47
48fn parse_one_key<'body>(
49 input: &'body str,
50 tool_name: &str,
51) -> Result<(String, &'body str), PairedQuoteFailure> {
52 let Some((key_raw, after_colon)) = input.split_once(':') else {
53 return Err(PairedQuoteFailure::UnclosedArgumentBlock {
54 tool_name: tool_name.to_owned(),
55 state: "key",
56 });
57 };
58 let key = key_raw.trim().to_owned();
59 if key.is_empty() {
60 return Err(PairedQuoteFailure::EmptyKey {
61 tool_name: tool_name.to_owned(),
62 });
63 }
64
65 Ok((key, after_colon))
66}
67
68fn parse_one_value<'body>(
69 input: &'body str,
70 value_quote: &ToolCallValueQuote,
71 close_marker: &str,
72 tool_name: &str,
73 key: &str,
74) -> Result<(serde_json::Value, &'body str), PairedQuoteFailure> {
75 let trimmed = input.trim_start();
76
77 if !value_quote.open.is_empty()
78 && !value_quote.close.is_empty()
79 && let Some(after_open) = trimmed.strip_prefix(value_quote.open.as_str())
80 {
81 let Some(close_position) = after_open.find(value_quote.close.as_str()) else {
82 return Err(PairedQuoteFailure::UnclosedQuotedValue {
83 tool_name: tool_name.to_owned(),
84 key: key.to_owned(),
85 });
86 };
87 let value_text = after_open[..close_position].to_owned();
88 let after_close = &after_open[close_position + value_quote.close.len()..];
89
90 return Ok((serde_json::Value::String(value_text), after_close));
91 }
92
93 let bare_end = find_bare_value_end(trimmed, close_marker);
94 let bare_text = trimmed[..bare_end].trim();
95 let value = bare_value_to_json(bare_text);
96
97 Ok((value, &trimmed[bare_end..]))
98}
99
100fn parse_args_body<'body>(
101 input: &'body str,
102 value_quote: &ToolCallValueQuote,
103 close_marker: &str,
104 tool_name: &str,
105) -> Result<(serde_json::Map<String, serde_json::Value>, &'body str), PairedQuoteFailure> {
106 let mut map = serde_json::Map::new();
107 let mut remaining = input.trim_start();
108
109 loop {
110 if remaining.is_empty() {
111 return Ok((map, remaining));
112 }
113 if !close_marker.is_empty()
114 && let Some(after_close) = remaining.strip_prefix(close_marker)
115 {
116 return Ok((map, after_close));
117 }
118
119 let (key, after_key) = parse_one_key(remaining, tool_name)?;
120 let (value, after_value) =
121 parse_one_value(after_key, value_quote, close_marker, tool_name, &key)?;
122 map.insert(key.clone(), value);
123
124 remaining = after_value.trim_start();
125 if !close_marker.is_empty()
126 && let Some(after_close) = remaining.strip_prefix(close_marker)
127 {
128 return Ok((map, after_close));
129 }
130 if let Some(after_comma) = remaining.strip_prefix(',') {
131 remaining = after_comma.trim_start();
132 continue;
133 }
134
135 let Some(character) = remaining.chars().next() else {
136 return Ok((map, remaining));
137 };
138
139 return Err(PairedQuoteFailure::UnexpectedCharAfterValue {
140 tool_name: tool_name.to_owned(),
141 key,
142 character,
143 });
144 }
145}
146
147fn parse_one_call<'body>(
148 input: &'body str,
149 markers: &ToolCallMarkers,
150 shape: &PairedQuoteShape,
151) -> Result<ParseStep<'body>, PairedQuoteFailure> {
152 if input.is_empty() {
153 return Ok(ParseStep::Done);
154 }
155
156 let after_open = consume_optional_prefix(input, markers.open.as_str());
157
158 let Some((name_raw, after_separator)) =
159 split_at_separator(after_open, shape.name_args_separator.as_str())
160 else {
161 return Ok(ParseStep::Done);
162 };
163
164 let name = name_raw.trim().to_owned();
165 if name.is_empty() {
166 return Ok(ParseStep::Done);
167 }
168
169 let (args_object, after_args) = parse_args_body(
170 after_separator,
171 &shape.value_quote,
172 markers.close.as_str(),
173 &name,
174 )?;
175 let arguments_value = serde_json::Value::Object(args_object);
176
177 Ok(ParseStep::Call(
178 ParsedToolCall::new(
179 String::new(),
180 name,
181 ToolCallArguments::ValidJson(arguments_value),
182 ),
183 after_args,
184 ))
185}
186
187pub fn parse(
194 body: &str,
195 markers: &ToolCallMarkers,
196 shape: &PairedQuoteShape,
197) -> Result<Vec<ParsedToolCall>, PairedQuoteFailure> {
198 if shape.name_args_separator.is_empty() {
199 return Ok(Vec::new());
200 }
201
202 let mut parsed = Vec::new();
203 let mut remaining = body.trim_start();
204
205 loop {
206 match parse_one_call(remaining, markers, shape)? {
207 ParseStep::Done => break,
208 ParseStep::Call(call, rest) => {
209 parsed.push(call);
210 remaining = rest.trim_start();
211 }
212 }
213 }
214
215 Ok(parsed)
216}
217
218#[cfg(test)]
219mod tests {
220 #![expect(
221 clippy::literal_string_with_formatting_args,
222 reason = "Gemma tool-call format literals contain braces that resemble format args"
223 )]
224
225 use llama_cpp_bindings_types::PairedQuoteShape;
226 use llama_cpp_bindings_types::ToolCallArgsShape;
227 use llama_cpp_bindings_types::ToolCallArguments;
228 use llama_cpp_bindings_types::ToolCallMarkers;
229 use llama_cpp_bindings_types::ToolCallValueQuote;
230 use serde_json::json;
231
232 use super::parse;
233 use crate::error::PairedQuoteFailure;
234
235 fn gemma4_markers() -> ToolCallMarkers {
236 ToolCallMarkers {
237 open: "<|tool_call>call:".to_owned(),
238 close: "}".to_owned(),
239 args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()),
240 }
241 }
242
243 fn gemma4_shape() -> PairedQuoteShape {
244 PairedQuoteShape {
245 name_args_separator: "{".to_owned(),
246 value_quote: ToolCallValueQuote {
247 open: "<|\"|>".to_owned(),
248 close: "<|\"|>".to_owned(),
249 },
250 }
251 }
252
253 #[test]
254 fn parses_single_quoted_string_argument_with_full_markers() {
255 let parsed = parse(
256 "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}",
257 &gemma4_markers(),
258 &gemma4_shape(),
259 )
260 .expect("must parse");
261
262 assert_eq!(parsed.len(), 1);
263 assert_eq!(parsed[0].name, "get_weather");
264 assert_eq!(
265 parsed[0].arguments,
266 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
267 );
268 }
269
270 #[test]
271 fn parses_classifier_stripped_body_without_open_or_close() {
272 let parsed = parse(
273 "get_weather{location:<|\"|>Paris<|\"|>",
274 &gemma4_markers(),
275 &gemma4_shape(),
276 )
277 .expect("must parse");
278
279 assert_eq!(parsed.len(), 1);
280 assert_eq!(parsed[0].name, "get_weather");
281 assert_eq!(
282 parsed[0].arguments,
283 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
284 );
285 }
286
287 #[test]
288 fn parses_multiple_quoted_string_arguments() {
289 let parsed = parse(
290 "<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}",
291 &gemma4_markers(),
292 &gemma4_shape(),
293 )
294 .expect("must parse");
295
296 assert_eq!(parsed.len(), 1);
297 assert_eq!(
298 parsed[0].arguments,
299 ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})),
300 );
301 }
302
303 #[test]
304 fn parses_bare_numeric_value() {
305 let parsed = parse(
306 "<|tool_call>call:f{a:42}",
307 &gemma4_markers(),
308 &gemma4_shape(),
309 )
310 .expect("must parse");
311
312 assert_eq!(
313 parsed[0].arguments,
314 ToolCallArguments::ValidJson(json!({"a": 42})),
315 );
316 }
317
318 #[test]
319 fn parses_bare_boolean_value() {
320 let parsed = parse(
321 "<|tool_call>call:f{a:true}",
322 &gemma4_markers(),
323 &gemma4_shape(),
324 )
325 .expect("must parse");
326
327 assert_eq!(
328 parsed[0].arguments,
329 ToolCallArguments::ValidJson(json!({"a": true})),
330 );
331 }
332
333 #[test]
334 fn rejects_unclosed_quoted_value_with_typed_failure() {
335 let result = parse(
336 "<|tool_call>call:f{a:<|\"|>oops",
337 &gemma4_markers(),
338 &gemma4_shape(),
339 );
340
341 assert_eq!(
342 result.expect_err("unclosed quote must produce a typed failure"),
343 PairedQuoteFailure::UnclosedQuotedValue {
344 tool_name: "f".to_owned(),
345 key: "a".to_owned(),
346 },
347 );
348 }
349
350 #[test]
351 fn rejects_unexpected_char_after_value_with_typed_failure() {
352 let result = parse(
353 "<|tool_call>call:f{a:<|\"|>v<|\"|>$bad}",
354 &gemma4_markers(),
355 &gemma4_shape(),
356 );
357
358 assert_eq!(
359 result.expect_err("garbage after value must produce a typed failure"),
360 PairedQuoteFailure::UnexpectedCharAfterValue {
361 tool_name: "f".to_owned(),
362 key: "a".to_owned(),
363 character: '$',
364 },
365 );
366 }
367
368 #[test]
369 fn returns_empty_vec_for_empty_body() {
370 let parsed = parse("", &gemma4_markers(), &gemma4_shape()).expect("empty body must parse");
371 assert!(parsed.is_empty());
372 }
373
374 #[test]
375 fn returns_empty_vec_when_body_lacks_separator() {
376 let parsed = parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())
377 .expect("body without separator must parse");
378 assert!(parsed.is_empty());
379 }
380
381 #[test]
382 fn parses_args_body_terminated_by_end_of_input_after_quoted_value() {
383 let parsed = parse(
384 "<|tool_call>call:f{x:<|\"|>v<|\"|>",
385 &gemma4_markers(),
386 &gemma4_shape(),
387 )
388 .expect("end-of-input after quoted value must parse");
389
390 assert_eq!(
391 parsed[0].arguments,
392 ToolCallArguments::ValidJson(json!({"x": "v"})),
393 );
394 }
395
396 #[test]
397 fn parses_args_body_terminated_by_end_of_input_after_bare_value() {
398 let parsed = parse(
399 "<|tool_call>call:f{n:42",
400 &gemma4_markers(),
401 &gemma4_shape(),
402 )
403 .expect("end-of-input after bare value must parse");
404
405 assert_eq!(
406 parsed[0].arguments,
407 ToolCallArguments::ValidJson(json!({"n": 42})),
408 );
409 }
410
411 #[test]
412 fn rejects_empty_key_with_typed_failure() {
413 let result = parse(
414 "<|tool_call>call:f{:42}",
415 &gemma4_markers(),
416 &gemma4_shape(),
417 );
418
419 assert_eq!(
420 result.expect_err("empty key must produce a typed failure"),
421 PairedQuoteFailure::EmptyKey {
422 tool_name: "f".to_owned(),
423 },
424 );
425 }
426
427 #[test]
428 fn rejects_args_body_without_key_colon_with_typed_failure() {
429 let result = parse(
430 "<|tool_call>call:f{noColonHere",
431 &gemma4_markers(),
432 &gemma4_shape(),
433 );
434
435 assert_eq!(
436 result.expect_err("args body without colon must produce a typed failure"),
437 PairedQuoteFailure::UnclosedArgumentBlock {
438 tool_name: "f".to_owned(),
439 state: "key",
440 },
441 );
442 }
443
444 #[test]
445 fn parses_empty_bare_value_as_null() {
446 let parsed = parse("<|tool_call>call:f{a:}", &gemma4_markers(), &gemma4_shape())
447 .expect("empty bare value must parse");
448
449 assert_eq!(
450 parsed[0].arguments,
451 ToolCallArguments::ValidJson(json!({"a": null})),
452 );
453 }
454
455 #[test]
456 fn parses_call_with_empty_args_body_terminated_by_end_of_input() {
457 let parsed = parse("<|tool_call>call:f{", &gemma4_markers(), &gemma4_shape())
458 .expect("empty args body must parse");
459
460 assert_eq!(parsed.len(), 1);
461 assert_eq!(parsed[0].name, "f");
462 assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({})),);
463 }
464
465 #[test]
466 fn parses_call_with_empty_args_body_closed_by_marker() {
467 let parsed = parse("<|tool_call>call:f{}", &gemma4_markers(), &gemma4_shape())
468 .expect("empty args body closed by marker must parse");
469
470 assert_eq!(parsed.len(), 1);
471 assert_eq!(parsed[0].name, "f");
472 assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({})),);
473 }
474
475 #[test]
476 fn stops_parsing_when_tool_name_is_empty() {
477 let parsed = parse(
478 "<|tool_call>call:{a:<|\"|>v<|\"|>}",
479 &gemma4_markers(),
480 &gemma4_shape(),
481 )
482 .expect("empty tool name must yield no calls");
483
484 assert!(parsed.is_empty());
485 }
486
487 #[test]
488 fn returns_empty_vec_when_separator_is_empty() {
489 let shape = PairedQuoteShape {
490 name_args_separator: String::new(),
491 value_quote: ToolCallValueQuote {
492 open: "<|\"|>".to_owned(),
493 close: "<|\"|>".to_owned(),
494 },
495 };
496 let parsed = parse(
497 "<|tool_call>call:f{a:<|\"|>v<|\"|>}",
498 &gemma4_markers(),
499 &shape,
500 )
501 .expect("empty separator must yield no calls");
502
503 assert!(parsed.is_empty());
504 }
505}