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 use llama_cpp_bindings_types::PairedQuoteShape;
221 use llama_cpp_bindings_types::ToolCallArgsShape;
222 use llama_cpp_bindings_types::ToolCallArguments;
223 use llama_cpp_bindings_types::ToolCallMarkers;
224 use llama_cpp_bindings_types::ToolCallValueQuote;
225 use serde_json::json;
226
227 use super::parse;
228 use crate::error::PairedQuoteFailure;
229
230 fn gemma4_markers() -> ToolCallMarkers {
231 ToolCallMarkers {
232 open: "<|tool_call>call:".to_owned(),
233 close: "}".to_owned(),
234 args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()),
235 }
236 }
237
238 fn gemma4_shape() -> PairedQuoteShape {
239 PairedQuoteShape {
240 name_args_separator: "{".to_owned(),
241 value_quote: ToolCallValueQuote {
242 open: "<|\"|>".to_owned(),
243 close: "<|\"|>".to_owned(),
244 },
245 }
246 }
247
248 #[test]
249 fn parses_single_quoted_string_argument_with_full_markers() {
250 let parsed = parse(
251 "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}",
252 &gemma4_markers(),
253 &gemma4_shape(),
254 )
255 .expect("must parse");
256
257 assert_eq!(parsed.len(), 1);
258 assert_eq!(parsed[0].name, "get_weather");
259 assert_eq!(
260 parsed[0].arguments,
261 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
262 );
263 }
264
265 #[test]
266 fn parses_classifier_stripped_body_without_open_or_close() {
267 let parsed = parse(
268 "get_weather{location:<|\"|>Paris<|\"|>",
269 &gemma4_markers(),
270 &gemma4_shape(),
271 )
272 .expect("must parse");
273
274 assert_eq!(parsed.len(), 1);
275 assert_eq!(parsed[0].name, "get_weather");
276 assert_eq!(
277 parsed[0].arguments,
278 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
279 );
280 }
281
282 #[test]
283 fn parses_multiple_quoted_string_arguments() {
284 let parsed = parse(
285 "<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}",
286 &gemma4_markers(),
287 &gemma4_shape(),
288 )
289 .expect("must parse");
290
291 assert_eq!(parsed.len(), 1);
292 assert_eq!(
293 parsed[0].arguments,
294 ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})),
295 );
296 }
297
298 #[test]
299 fn parses_bare_numeric_value() {
300 let parsed = parse(
301 "<|tool_call>call:f{a:42}",
302 &gemma4_markers(),
303 &gemma4_shape(),
304 )
305 .expect("must parse");
306
307 assert_eq!(
308 parsed[0].arguments,
309 ToolCallArguments::ValidJson(json!({"a": 42})),
310 );
311 }
312
313 #[test]
314 fn parses_bare_boolean_value() {
315 let parsed = parse(
316 "<|tool_call>call:f{a:true}",
317 &gemma4_markers(),
318 &gemma4_shape(),
319 )
320 .expect("must parse");
321
322 assert_eq!(
323 parsed[0].arguments,
324 ToolCallArguments::ValidJson(json!({"a": true})),
325 );
326 }
327
328 #[test]
329 fn rejects_unclosed_quoted_value_with_typed_failure() {
330 let result = parse(
331 "<|tool_call>call:f{a:<|\"|>oops",
332 &gemma4_markers(),
333 &gemma4_shape(),
334 );
335
336 assert_eq!(
337 result.expect_err("unclosed quote must produce a typed failure"),
338 PairedQuoteFailure::UnclosedQuotedValue {
339 tool_name: "f".to_owned(),
340 key: "a".to_owned(),
341 },
342 );
343 }
344
345 #[test]
346 fn rejects_unexpected_char_after_value_with_typed_failure() {
347 let result = parse(
348 "<|tool_call>call:f{a:<|\"|>v<|\"|>$bad}",
349 &gemma4_markers(),
350 &gemma4_shape(),
351 );
352
353 assert_eq!(
354 result.expect_err("garbage after value must produce a typed failure"),
355 PairedQuoteFailure::UnexpectedCharAfterValue {
356 tool_name: "f".to_owned(),
357 key: "a".to_owned(),
358 character: '$',
359 },
360 );
361 }
362
363 #[test]
364 fn returns_empty_vec_for_empty_body() {
365 let parsed = parse("", &gemma4_markers(), &gemma4_shape()).expect("empty body must parse");
366 assert!(parsed.is_empty());
367 }
368
369 #[test]
370 fn returns_empty_vec_when_body_lacks_separator() {
371 let parsed = parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())
372 .expect("body without separator must parse");
373 assert!(parsed.is_empty());
374 }
375
376 #[test]
377 fn parses_args_body_terminated_by_end_of_input_after_quoted_value() {
378 let parsed = parse(
379 "<|tool_call>call:f{x:<|\"|>v<|\"|>",
380 &gemma4_markers(),
381 &gemma4_shape(),
382 )
383 .expect("end-of-input after quoted value must parse");
384
385 assert_eq!(
386 parsed[0].arguments,
387 ToolCallArguments::ValidJson(json!({"x": "v"})),
388 );
389 }
390
391 #[test]
392 fn parses_args_body_terminated_by_end_of_input_after_bare_value() {
393 let parsed = parse(
394 "<|tool_call>call:f{n:42",
395 &gemma4_markers(),
396 &gemma4_shape(),
397 )
398 .expect("end-of-input after bare value must parse");
399
400 assert_eq!(
401 parsed[0].arguments,
402 ToolCallArguments::ValidJson(json!({"n": 42})),
403 );
404 }
405
406 #[test]
407 fn rejects_empty_key_with_typed_failure() {
408 let result = parse(
409 "<|tool_call>call:f{:42}",
410 &gemma4_markers(),
411 &gemma4_shape(),
412 );
413
414 assert_eq!(
415 result.expect_err("empty key must produce a typed failure"),
416 PairedQuoteFailure::EmptyKey {
417 tool_name: "f".to_owned(),
418 },
419 );
420 }
421
422 #[test]
423 fn rejects_args_body_without_key_colon_with_typed_failure() {
424 let result = parse(
425 "<|tool_call>call:f{noColonHere",
426 &gemma4_markers(),
427 &gemma4_shape(),
428 );
429
430 assert_eq!(
431 result.expect_err("args body without colon must produce a typed failure"),
432 PairedQuoteFailure::UnclosedArgumentBlock {
433 tool_name: "f".to_owned(),
434 state: "key",
435 },
436 );
437 }
438
439 #[test]
440 fn parses_empty_bare_value_as_null() {
441 let parsed = parse("<|tool_call>call:f{a:}", &gemma4_markers(), &gemma4_shape())
442 .expect("empty bare value must parse");
443
444 assert_eq!(
445 parsed[0].arguments,
446 ToolCallArguments::ValidJson(json!({"a": null})),
447 );
448 }
449
450 #[test]
451 fn parses_call_with_empty_args_body_terminated_by_end_of_input() {
452 let parsed = parse("<|tool_call>call:f{", &gemma4_markers(), &gemma4_shape())
453 .expect("empty args body must parse");
454
455 assert_eq!(parsed.len(), 1);
456 assert_eq!(parsed[0].name, "f");
457 assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({})),);
458 }
459
460 #[test]
461 fn parses_call_with_empty_args_body_closed_by_marker() {
462 let parsed = parse("<|tool_call>call:f{}", &gemma4_markers(), &gemma4_shape())
463 .expect("empty args body closed by marker must parse");
464
465 assert_eq!(parsed.len(), 1);
466 assert_eq!(parsed[0].name, "f");
467 assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({})),);
468 }
469
470 #[test]
471 fn stops_parsing_when_tool_name_is_empty() {
472 let parsed = parse(
473 "<|tool_call>call:{a:<|\"|>v<|\"|>}",
474 &gemma4_markers(),
475 &gemma4_shape(),
476 )
477 .expect("empty tool name must yield no calls");
478
479 assert!(parsed.is_empty());
480 }
481
482 #[test]
483 fn returns_empty_vec_when_separator_is_empty() {
484 let shape = PairedQuoteShape {
485 name_args_separator: String::new(),
486 value_quote: ToolCallValueQuote {
487 open: "<|\"|>".to_owned(),
488 close: "<|\"|>".to_owned(),
489 },
490 };
491 let parsed = parse(
492 "<|tool_call>call:f{a:<|\"|>v<|\"|>}",
493 &gemma4_markers(),
494 &shape,
495 )
496 .expect("empty separator must yield no calls");
497
498 assert!(parsed.is_empty());
499 }
500}