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 remaining.is_empty() {
126 return Ok((map, remaining));
127 }
128 if !close_marker.is_empty()
129 && let Some(after_close) = remaining.strip_prefix(close_marker)
130 {
131 return Ok((map, after_close));
132 }
133 if let Some(after_comma) = remaining.strip_prefix(',') {
134 remaining = after_comma.trim_start();
135 continue;
136 }
137
138 let Some(character) = remaining.chars().next() else {
139 return Ok((map, remaining));
140 };
141
142 return Err(PairedQuoteFailure::UnexpectedCharAfterValue {
143 tool_name: tool_name.to_owned(),
144 key,
145 character,
146 });
147 }
148}
149
150fn parse_one_call<'body>(
151 input: &'body str,
152 markers: &ToolCallMarkers,
153 shape: &PairedQuoteShape,
154) -> Result<ParseStep<'body>, PairedQuoteFailure> {
155 if input.is_empty() {
156 return Ok(ParseStep::Done);
157 }
158
159 let after_open = consume_optional_prefix(input, markers.open.as_str());
160
161 let Some((name_raw, after_separator)) =
162 split_at_separator(after_open, shape.name_args_separator.as_str())
163 else {
164 return Ok(ParseStep::Done);
165 };
166
167 let name = name_raw.trim().to_owned();
168 if name.is_empty() {
169 return Ok(ParseStep::Done);
170 }
171
172 let (args_object, after_args) = parse_args_body(
173 after_separator,
174 &shape.value_quote,
175 markers.close.as_str(),
176 &name,
177 )?;
178 let arguments_value = serde_json::Value::Object(args_object);
179
180 Ok(ParseStep::Call(
181 ParsedToolCall::new(
182 String::new(),
183 name,
184 ToolCallArguments::ValidJson(arguments_value),
185 ),
186 after_args,
187 ))
188}
189
190pub fn parse(
197 body: &str,
198 markers: &ToolCallMarkers,
199 shape: &PairedQuoteShape,
200) -> Result<Vec<ParsedToolCall>, PairedQuoteFailure> {
201 if shape.name_args_separator.is_empty() {
202 return Ok(Vec::new());
203 }
204
205 let mut parsed = Vec::new();
206 let mut remaining = body.trim_start();
207
208 loop {
209 match parse_one_call(remaining, markers, shape)? {
210 ParseStep::Done => break,
211 ParseStep::Call(call, rest) => {
212 parsed.push(call);
213 remaining = rest.trim_start();
214 }
215 }
216 }
217
218 Ok(parsed)
219}
220
221#[cfg(test)]
222mod tests {
223 #![expect(
224 clippy::literal_string_with_formatting_args,
225 reason = "Gemma tool-call format literals contain braces that resemble format args"
226 )]
227
228 use llama_cpp_bindings_types::PairedQuoteShape;
229 use llama_cpp_bindings_types::ToolCallArgsShape;
230 use llama_cpp_bindings_types::ToolCallArguments;
231 use llama_cpp_bindings_types::ToolCallMarkers;
232 use llama_cpp_bindings_types::ToolCallValueQuote;
233 use serde_json::json;
234
235 use super::parse;
236 use crate::error::PairedQuoteFailure;
237
238 fn gemma4_markers() -> ToolCallMarkers {
239 ToolCallMarkers {
240 open: "<|tool_call>call:".to_owned(),
241 close: "}".to_owned(),
242 args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()),
243 }
244 }
245
246 fn gemma4_shape() -> PairedQuoteShape {
247 PairedQuoteShape {
248 name_args_separator: "{".to_owned(),
249 value_quote: ToolCallValueQuote {
250 open: "<|\"|>".to_owned(),
251 close: "<|\"|>".to_owned(),
252 },
253 }
254 }
255
256 #[test]
257 fn parses_single_quoted_string_argument_with_full_markers() {
258 let parsed = parse(
259 "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}",
260 &gemma4_markers(),
261 &gemma4_shape(),
262 )
263 .expect("must parse");
264
265 assert_eq!(parsed.len(), 1);
266 assert_eq!(parsed[0].name, "get_weather");
267 assert_eq!(
268 parsed[0].arguments,
269 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
270 );
271 }
272
273 #[test]
274 fn parses_classifier_stripped_body_without_open_or_close() {
275 let parsed = parse(
276 "get_weather{location:<|\"|>Paris<|\"|>",
277 &gemma4_markers(),
278 &gemma4_shape(),
279 )
280 .expect("must parse");
281
282 assert_eq!(parsed.len(), 1);
283 assert_eq!(parsed[0].name, "get_weather");
284 assert_eq!(
285 parsed[0].arguments,
286 ToolCallArguments::ValidJson(json!({"location": "Paris"})),
287 );
288 }
289
290 #[test]
291 fn parses_multiple_quoted_string_arguments() {
292 let parsed = parse(
293 "<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}",
294 &gemma4_markers(),
295 &gemma4_shape(),
296 )
297 .expect("must parse");
298
299 assert_eq!(parsed.len(), 1);
300 assert_eq!(
301 parsed[0].arguments,
302 ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})),
303 );
304 }
305
306 #[test]
307 fn parses_bare_numeric_value() {
308 let parsed = parse(
309 "<|tool_call>call:f{a:42}",
310 &gemma4_markers(),
311 &gemma4_shape(),
312 )
313 .expect("must parse");
314
315 assert_eq!(
316 parsed[0].arguments,
317 ToolCallArguments::ValidJson(json!({"a": 42})),
318 );
319 }
320
321 #[test]
322 fn parses_bare_boolean_value() {
323 let parsed = parse(
324 "<|tool_call>call:f{a:true}",
325 &gemma4_markers(),
326 &gemma4_shape(),
327 )
328 .expect("must parse");
329
330 assert_eq!(
331 parsed[0].arguments,
332 ToolCallArguments::ValidJson(json!({"a": true})),
333 );
334 }
335
336 #[test]
337 fn rejects_unclosed_quoted_value_with_typed_failure() {
338 let result = parse(
339 "<|tool_call>call:f{a:<|\"|>oops",
340 &gemma4_markers(),
341 &gemma4_shape(),
342 );
343
344 match result.expect_err("unclosed quote must produce a typed failure") {
345 PairedQuoteFailure::UnclosedQuotedValue { tool_name, key } => {
346 assert_eq!(tool_name, "f");
347 assert_eq!(key, "a");
348 }
349 other => panic!("expected UnclosedQuotedValue, got {other:?}"),
350 }
351 }
352
353 #[test]
354 fn rejects_unexpected_char_after_value_with_typed_failure() {
355 let result = parse(
356 "<|tool_call>call:f{a:<|\"|>v<|\"|>$bad}",
357 &gemma4_markers(),
358 &gemma4_shape(),
359 );
360
361 match result.expect_err("garbage after value must produce a typed failure") {
362 PairedQuoteFailure::UnexpectedCharAfterValue {
363 tool_name,
364 key,
365 character,
366 } => {
367 assert_eq!(tool_name, "f");
368 assert_eq!(key, "a");
369 assert_eq!(character, '$');
370 }
371 other => panic!("expected UnexpectedCharAfterValue, got {other:?}"),
372 }
373 }
374
375 #[test]
376 fn returns_empty_vec_for_empty_body() {
377 let parsed = parse("", &gemma4_markers(), &gemma4_shape()).expect("empty body must parse");
378 assert!(parsed.is_empty());
379 }
380
381 #[test]
382 fn returns_empty_vec_when_body_lacks_separator() {
383 let parsed = parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())
384 .expect("body without separator must parse");
385 assert!(parsed.is_empty());
386 }
387
388 #[test]
389 fn parses_args_body_terminated_by_end_of_input_after_quoted_value() {
390 let parsed = parse(
391 "<|tool_call>call:f{x:<|\"|>v<|\"|>",
392 &gemma4_markers(),
393 &gemma4_shape(),
394 )
395 .expect("end-of-input after quoted value must parse");
396
397 assert_eq!(
398 parsed[0].arguments,
399 ToolCallArguments::ValidJson(json!({"x": "v"})),
400 );
401 }
402
403 #[test]
404 fn parses_args_body_terminated_by_end_of_input_after_bare_value() {
405 let parsed = parse(
406 "<|tool_call>call:f{n:42",
407 &gemma4_markers(),
408 &gemma4_shape(),
409 )
410 .expect("end-of-input after bare value must parse");
411
412 assert_eq!(
413 parsed[0].arguments,
414 ToolCallArguments::ValidJson(json!({"n": 42})),
415 );
416 }
417
418 #[test]
419 fn rejects_empty_key_with_typed_failure() {
420 let result = parse(
421 "<|tool_call>call:f{:42}",
422 &gemma4_markers(),
423 &gemma4_shape(),
424 );
425
426 match result.expect_err("empty key must produce a typed failure") {
427 PairedQuoteFailure::EmptyKey { tool_name } => {
428 assert_eq!(tool_name, "f");
429 }
430 other => panic!("expected EmptyKey, got {other:?}"),
431 }
432 }
433
434 #[test]
435 fn rejects_args_body_without_key_colon_with_typed_failure() {
436 let result = parse(
437 "<|tool_call>call:f{noColonHere",
438 &gemma4_markers(),
439 &gemma4_shape(),
440 );
441
442 match result.expect_err("args body without colon must produce a typed failure") {
443 PairedQuoteFailure::UnclosedArgumentBlock { tool_name, state } => {
444 assert_eq!(tool_name, "f");
445 assert_eq!(state, "key");
446 }
447 other => panic!("expected UnclosedArgumentBlock, got {other:?}"),
448 }
449 }
450}