use llama_cpp_bindings_types::PairedQuoteShape;
use llama_cpp_bindings_types::ParsedToolCall;
use llama_cpp_bindings_types::ToolCallArguments;
use llama_cpp_bindings_types::ToolCallMarkers;
use llama_cpp_bindings_types::ToolCallValueQuote;
use crate::error::PairedQuoteFailure;
enum ParseStep<'body> {
Done,
Call(ParsedToolCall, &'body str),
}
fn consume_optional_prefix<'body>(input: &'body str, literal: &str) -> &'body str {
input.strip_prefix(literal).unwrap_or(input)
}
fn split_at_separator<'body>(
input: &'body str,
separator: &str,
) -> Option<(&'body str, &'body str)> {
let (name_raw, after_separator) = input.split_once(separator)?;
Some((name_raw, after_separator))
}
fn bare_value_to_json(text: &str) -> serde_json::Value {
if text.is_empty() {
return serde_json::Value::Null;
}
serde_json::from_str::<serde_json::Value>(text)
.ok()
.unwrap_or_else(|| serde_json::Value::String(text.to_owned()))
}
fn find_bare_value_end(input: &str, close_marker: &str) -> usize {
for (byte_index, character) in input.char_indices() {
if character == ',' {
return byte_index;
}
if !close_marker.is_empty() && input[byte_index..].starts_with(close_marker) {
return byte_index;
}
}
input.len()
}
fn parse_one_key<'body>(
input: &'body str,
tool_name: &str,
) -> Result<(String, &'body str), PairedQuoteFailure> {
let Some((key_raw, after_colon)) = input.split_once(':') else {
return Err(PairedQuoteFailure::UnclosedArgumentBlock {
tool_name: tool_name.to_owned(),
state: "key",
});
};
let key = key_raw.trim().to_owned();
if key.is_empty() {
return Err(PairedQuoteFailure::EmptyKey {
tool_name: tool_name.to_owned(),
});
}
Ok((key, after_colon))
}
fn parse_one_value<'body>(
input: &'body str,
value_quote: &ToolCallValueQuote,
close_marker: &str,
tool_name: &str,
key: &str,
) -> Result<(serde_json::Value, &'body str), PairedQuoteFailure> {
let trimmed = input.trim_start();
if !value_quote.open.is_empty()
&& !value_quote.close.is_empty()
&& let Some(after_open) = trimmed.strip_prefix(value_quote.open.as_str())
{
let Some(close_position) = after_open.find(value_quote.close.as_str()) else {
return Err(PairedQuoteFailure::UnclosedQuotedValue {
tool_name: tool_name.to_owned(),
key: key.to_owned(),
});
};
let value_text = after_open[..close_position].to_owned();
let after_close = &after_open[close_position + value_quote.close.len()..];
return Ok((serde_json::Value::String(value_text), after_close));
}
let bare_end = find_bare_value_end(trimmed, close_marker);
let bare_text = trimmed[..bare_end].trim();
let value = bare_value_to_json(bare_text);
Ok((value, &trimmed[bare_end..]))
}
fn parse_args_body<'body>(
input: &'body str,
value_quote: &ToolCallValueQuote,
close_marker: &str,
tool_name: &str,
) -> Result<(serde_json::Map<String, serde_json::Value>, &'body str), PairedQuoteFailure> {
let mut map = serde_json::Map::new();
let mut remaining = input.trim_start();
loop {
if remaining.is_empty() {
return Ok((map, remaining));
}
if !close_marker.is_empty()
&& let Some(after_close) = remaining.strip_prefix(close_marker)
{
return Ok((map, after_close));
}
let (key, after_key) = parse_one_key(remaining, tool_name)?;
let (value, after_value) =
parse_one_value(after_key, value_quote, close_marker, tool_name, &key)?;
map.insert(key.clone(), value);
remaining = after_value.trim_start();
if remaining.is_empty() {
return Ok((map, remaining));
}
if !close_marker.is_empty()
&& let Some(after_close) = remaining.strip_prefix(close_marker)
{
return Ok((map, after_close));
}
if let Some(after_comma) = remaining.strip_prefix(',') {
remaining = after_comma.trim_start();
continue;
}
let Some(character) = remaining.chars().next() else {
return Ok((map, remaining));
};
return Err(PairedQuoteFailure::UnexpectedCharAfterValue {
tool_name: tool_name.to_owned(),
key,
character,
});
}
}
fn parse_one_call<'body>(
input: &'body str,
markers: &ToolCallMarkers,
shape: &PairedQuoteShape,
) -> Result<ParseStep<'body>, PairedQuoteFailure> {
if input.is_empty() {
return Ok(ParseStep::Done);
}
let after_open = consume_optional_prefix(input, markers.open.as_str());
let Some((name_raw, after_separator)) =
split_at_separator(after_open, shape.name_args_separator.as_str())
else {
return Ok(ParseStep::Done);
};
let name = name_raw.trim().to_owned();
if name.is_empty() {
return Ok(ParseStep::Done);
}
let (args_object, after_args) = parse_args_body(
after_separator,
&shape.value_quote,
markers.close.as_str(),
&name,
)?;
let arguments_value = serde_json::Value::Object(args_object);
Ok(ParseStep::Call(
ParsedToolCall::new(
String::new(),
name,
ToolCallArguments::ValidJson(arguments_value),
),
after_args,
))
}
pub fn parse(
body: &str,
markers: &ToolCallMarkers,
shape: &PairedQuoteShape,
) -> Result<Vec<ParsedToolCall>, PairedQuoteFailure> {
if shape.name_args_separator.is_empty() {
return Ok(Vec::new());
}
let mut parsed = Vec::new();
let mut remaining = body.trim_start();
loop {
match parse_one_call(remaining, markers, shape)? {
ParseStep::Done => break,
ParseStep::Call(call, rest) => {
parsed.push(call);
remaining = rest.trim_start();
}
}
}
Ok(parsed)
}
#[cfg(test)]
mod tests {
#![expect(
clippy::literal_string_with_formatting_args,
reason = "Gemma tool-call format literals contain braces that resemble format args"
)]
use llama_cpp_bindings_types::PairedQuoteShape;
use llama_cpp_bindings_types::ToolCallArgsShape;
use llama_cpp_bindings_types::ToolCallArguments;
use llama_cpp_bindings_types::ToolCallMarkers;
use llama_cpp_bindings_types::ToolCallValueQuote;
use serde_json::json;
use super::parse;
use crate::error::PairedQuoteFailure;
fn gemma4_markers() -> ToolCallMarkers {
ToolCallMarkers {
open: "<|tool_call>call:".to_owned(),
close: "}".to_owned(),
args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()),
}
}
fn gemma4_shape() -> PairedQuoteShape {
PairedQuoteShape {
name_args_separator: "{".to_owned(),
value_quote: ToolCallValueQuote {
open: "<|\"|>".to_owned(),
close: "<|\"|>".to_owned(),
},
}
}
#[test]
fn parses_single_quoted_string_argument_with_full_markers() {
let parsed = parse(
"<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}",
&gemma4_markers(),
&gemma4_shape(),
)
.expect("must parse");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name, "get_weather");
assert_eq!(
parsed[0].arguments,
ToolCallArguments::ValidJson(json!({"location": "Paris"})),
);
}
#[test]
fn parses_classifier_stripped_body_without_open_or_close() {
let parsed = parse(
"get_weather{location:<|\"|>Paris<|\"|>",
&gemma4_markers(),
&gemma4_shape(),
)
.expect("must parse");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name, "get_weather");
assert_eq!(
parsed[0].arguments,
ToolCallArguments::ValidJson(json!({"location": "Paris"})),
);
}
#[test]
fn parses_multiple_quoted_string_arguments() {
let parsed = parse(
"<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}",
&gemma4_markers(),
&gemma4_shape(),
)
.expect("must parse");
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0].arguments,
ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})),
);
}
#[test]
fn parses_bare_numeric_value() {
let parsed = parse(
"<|tool_call>call:f{a:42}",
&gemma4_markers(),
&gemma4_shape(),
)
.expect("must parse");
assert_eq!(
parsed[0].arguments,
ToolCallArguments::ValidJson(json!({"a": 42})),
);
}
#[test]
fn parses_bare_boolean_value() {
let parsed = parse(
"<|tool_call>call:f{a:true}",
&gemma4_markers(),
&gemma4_shape(),
)
.expect("must parse");
assert_eq!(
parsed[0].arguments,
ToolCallArguments::ValidJson(json!({"a": true})),
);
}
#[test]
fn rejects_unclosed_quoted_value_with_typed_failure() {
let result = parse(
"<|tool_call>call:f{a:<|\"|>oops",
&gemma4_markers(),
&gemma4_shape(),
);
match result.expect_err("unclosed quote must produce a typed failure") {
PairedQuoteFailure::UnclosedQuotedValue { tool_name, key } => {
assert_eq!(tool_name, "f");
assert_eq!(key, "a");
}
other => panic!("expected UnclosedQuotedValue, got {other:?}"),
}
}
#[test]
fn rejects_unexpected_char_after_value_with_typed_failure() {
let result = parse(
"<|tool_call>call:f{a:<|\"|>v<|\"|>$bad}",
&gemma4_markers(),
&gemma4_shape(),
);
match result.expect_err("garbage after value must produce a typed failure") {
PairedQuoteFailure::UnexpectedCharAfterValue {
tool_name,
key,
character,
} => {
assert_eq!(tool_name, "f");
assert_eq!(key, "a");
assert_eq!(character, '$');
}
other => panic!("expected UnexpectedCharAfterValue, got {other:?}"),
}
}
#[test]
fn returns_empty_vec_for_empty_body() {
let parsed = parse("", &gemma4_markers(), &gemma4_shape()).expect("empty body must parse");
assert!(parsed.is_empty());
}
#[test]
fn returns_empty_vec_when_body_lacks_separator() {
let parsed = parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())
.expect("body without separator must parse");
assert!(parsed.is_empty());
}
#[test]
fn parses_args_body_terminated_by_end_of_input_after_quoted_value() {
let parsed = parse(
"<|tool_call>call:f{x:<|\"|>v<|\"|>",
&gemma4_markers(),
&gemma4_shape(),
)
.expect("end-of-input after quoted value must parse");
assert_eq!(
parsed[0].arguments,
ToolCallArguments::ValidJson(json!({"x": "v"})),
);
}
#[test]
fn parses_args_body_terminated_by_end_of_input_after_bare_value() {
let parsed = parse(
"<|tool_call>call:f{n:42",
&gemma4_markers(),
&gemma4_shape(),
)
.expect("end-of-input after bare value must parse");
assert_eq!(
parsed[0].arguments,
ToolCallArguments::ValidJson(json!({"n": 42})),
);
}
#[test]
fn rejects_empty_key_with_typed_failure() {
let result = parse(
"<|tool_call>call:f{:42}",
&gemma4_markers(),
&gemma4_shape(),
);
match result.expect_err("empty key must produce a typed failure") {
PairedQuoteFailure::EmptyKey { tool_name } => {
assert_eq!(tool_name, "f");
}
other => panic!("expected EmptyKey, got {other:?}"),
}
}
#[test]
fn rejects_args_body_without_key_colon_with_typed_failure() {
let result = parse(
"<|tool_call>call:f{noColonHere",
&gemma4_markers(),
&gemma4_shape(),
);
match result.expect_err("args body without colon must produce a typed failure") {
PairedQuoteFailure::UnclosedArgumentBlock { tool_name, state } => {
assert_eq!(tool_name, "f");
assert_eq!(state, "key");
}
other => panic!("expected UnclosedArgumentBlock, got {other:?}"),
}
}
}