mod exclude;
mod wire;
#[cfg(test)]
mod format_tests;
use std::sync::Arc;
use axum::http::StatusCode;
use axum::Json;
use ferrox_models::grammar::json_schema::GrammarBuilder;
use ferrox_models::grammar::Grammar;
use crate::policy::parser::ToolCallFormat;
use crate::ApiError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Forced<'a> {
Any,
Named(&'a str),
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ToolSpec<'a> {
pub name: &'a str,
pub parameters: Option<&'a serde_json::Value>,
}
pub(crate) fn build(
forced: Forced<'_>,
tools: &[ToolSpec<'_>],
format: ToolCallFormat,
) -> Result<Arc<Grammar>, ApiError> {
let chosen = select(forced, tools)?;
for tool in &chosen {
check_name(tool.name)?;
}
let mut builder = GrammarBuilder::new();
let (root, triggers) = wire::build_root(&mut builder, format, &chosen)?;
builder.add_rule("root", &root);
let text = builder.finish().map_err(|e| {
internal(format!("tool-call grammar failed to build: {e}"))
})?;
let grammar = Grammar::from_str_with_root(&text, "root")
.map_err(|e| internal(format!("tool-call grammar does not compile: {e}")))?
.into_lazy(triggers)
.map_err(|e| internal(format!("tool-call grammar cannot be made lazy: {e}")))?;
Ok(Arc::new(grammar))
}
fn select<'a>(forced: Forced<'_>, tools: &[ToolSpec<'a>]) -> Result<Vec<ToolSpec<'a>>, ApiError> {
if tools.is_empty() {
return Err(invalid(
"tool_choice forces a tool call, but no tools were offered; send \"tools\", or use \
tool_choice \"none\"",
"tool_choice",
));
}
match forced {
Forced::Any => Ok(tools.to_vec()),
Forced::Named(name) => match tools.iter().find(|t| t.name == name) {
Some(t) => Ok(vec![*t]),
None => Err(invalid(
format!("tool_choice names {name:?}, which is not one of the tools offered"),
"tool_choice",
)),
},
}
}
fn check_name(name: &str) -> Result<(), ApiError> {
let ok = !name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.');
if ok {
return Ok(());
}
Err(invalid(
format!(
"tool name {name:?} cannot be forced: a forced tool call puts the name in a grammar, \
and this server accepts only names of 1..=64 characters from [A-Za-z0-9_.-] there"
),
"tools",
))
}
fn escape(literal: &str) -> String {
literal.replace('\\', r"\\").replace('"', "\\\"")
}
fn schema_refused(tool: &str, err: &ferrox_models::grammar::SchemaError) -> ApiError {
invalid(
format!(
"tool {tool:?} cannot be forced: its \"parameters\" schema does not convert to a \
grammar: {err}"
),
"tools",
)
}
fn invalid(message: impl Into<String>, param: &str) -> ApiError {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": message.into(),
"type": "invalid_request_error",
"param": param,
}
})),
)
}
fn unsupported(message: impl Into<String>) -> ApiError {
(
StatusCode::NOT_IMPLEMENTED,
Json(serde_json::json!({
"error": {
"message": message.into(),
"type": "invalid_request_error",
"param": "tool_choice",
}
})),
)
}
fn internal(message: String) -> ApiError {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": message,
"type": "server_error",
}
})),
)
}
#[cfg(test)]
mod tests {
use super::format_tests::feed;
use super::*;
use crate::output::{parse_output, OutputPosture};
use crate::{ToolDef, ToolFunctionDef};
fn weather() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": false,
})
}
fn specs<'a>(defs: &'a [(&'a str, &'a serde_json::Value)]) -> Vec<ToolSpec<'a>> {
defs.iter()
.map(|(name, params)| ToolSpec {
name,
parameters: Some(params),
})
.collect()
}
#[test]
fn the_forced_grammar_accepts_what_the_parser_reads_back() {
let params = weather();
let offered = [("get_weather", ¶ms)];
let g = build(Forced::Any, &specs(&offered), ToolCallFormat::Qwen25)
.expect("a grammar for one tool");
let call =
r#"<tool_call>{"name": "get_weather", "arguments": {"city": "Rome"}}</tool_call>"#;
assert!(
feed(&g, &["thinking about it... ", call]).expect("the grammar accepts the call"),
"the parse should be complete after the closing marker"
);
let tools = vec![ToolDef {
kind: "function".to_string(),
function: ToolFunctionDef {
name: "get_weather".to_string(),
description: None,
parameters: Some(params.clone()),
},
}];
let parsed = parse_output(
&format!("thinking about it... {call}"),
&tools,
OutputPosture::for_model("test-model"),
);
assert_eq!(
parsed.calls.len(),
1,
"the grammar and the parser must agree on the wire format"
);
assert_eq!(parsed.calls[0].name, "get_weather");
}
#[test]
fn a_required_property_cannot_be_omitted() {
let params = weather();
let offered = [("get_weather", ¶ms)];
let g = build(Forced::Any, &specs(&offered), ToolCallFormat::Qwen25).unwrap();
let err = feed(
&g,
&[r#"<tool_call>{"name": "get_weather", "arguments": {}}"#],
)
.expect_err("\"city\" is required");
assert!(err.contains("no grammar parse survives"), "{err}");
}
#[test]
fn a_named_choice_narrows_the_union_to_one_tool() {
let params = weather();
let offered = [("get_weather", ¶ms), ("send_mail", ¶ms)];
let tools = specs(&offered);
let any = build(Forced::Any, &tools, ToolCallFormat::Qwen25).unwrap();
assert!(feed(
&any,
&[r#"<tool_call>{"name": "send_mail", "arguments": {"city": "Rome"}}</tool_call>"#]
)
.is_ok());
let named = build(Forced::Named("get_weather"), &tools, ToolCallFormat::Qwen25).unwrap();
assert!(
feed(&named, &[r#"<tool_call>{"name": "send_mail""#]).is_err(),
"a named tool_choice must make every other tool unreachable"
);
assert!(feed(
&named,
&[r#"<tool_call>{"name": "get_weather", "arguments": {"city": "Rome"}}</tool_call>"#]
)
.is_ok());
}
#[test]
fn a_reasoning_block_may_precede_the_call() {
let params = weather();
let offered = [("get_weather", ¶ms)];
let g = build(Forced::Any, &specs(&offered), ToolCallFormat::Qwen25).unwrap();
assert!(g.is_awaiting_trigger());
assert!(
feed(
&g,
&[
"<think>",
"the user wants weather; I should call the tool.",
"</think>",
r#"<tool_call>{"name": "get_weather", "arguments": {"city": "Rome"}}</tool_call>"#,
]
)
.expect("thinking first is allowed"),
"the call must still complete after a reasoning block"
);
}
#[test]
fn the_turn_cannot_end_before_the_call_begins() {
let params = weather();
let offered = [("get_weather", ¶ms)];
let g = build(Forced::Any, &specs(&offered), ToolCallFormat::Qwen25).unwrap();
assert!(!g.allows_eog(), "nothing has been called yet");
let mut mid = (*g).clone();
mid.accept_token(0, b"I think the answer is 4.").unwrap();
assert!(
!mid.allows_eog(),
"prose must not be allowed to finish the turn"
);
}
#[test]
fn each_supported_format_uses_its_own_markers() {
let params = weather();
let offered = [("get_weather", ¶ms)];
let tools = specs(&offered);
let llama = build(Forced::Any, &tools, ToolCallFormat::Llama3).unwrap();
assert!(feed(
&llama,
&[r#"<|python_tag|>{"name": "get_weather", "arguments": {"city": "Rome"}}"#]
)
.unwrap());
let mistral = build(Forced::Any, &tools, ToolCallFormat::Mistral).unwrap();
assert!(feed(
&mistral,
&[r#"[TOOL_CALLS] [{"name": "get_weather", "arguments": {"city": "Rome"}}]"#]
)
.unwrap());
let (status, Json(body)) = build(Forced::Any, &tools, ToolCallFormat::Gemma4)
.expect_err("a gemma4 call's arguments are not an object rule this can write");
assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("gemma4"),
"the refusal must name the format: {body}"
);
}
#[test]
fn a_tool_without_parameters_takes_an_empty_object() {
let g = build(
Forced::Any,
&[ToolSpec {
name: "ping",
parameters: None,
}],
ToolCallFormat::Qwen25,
)
.unwrap();
assert!(feed(
&g,
&[r#"<tool_call>{"name": "ping", "arguments": {}}</tool_call>"#]
)
.unwrap());
assert!(
feed(&g, &[r#"<tool_call>{"name": "ping", "arguments": {"x""#]).is_err(),
"a tool that declares no parameters must not accept invented ones"
);
}
#[test]
fn the_refusals_name_what_is_wrong() {
let params = weather();
let (status, _) =
build(Forced::Any, &[], ToolCallFormat::Qwen25).expect_err("nothing to choose between");
assert_eq!(status, StatusCode::BAD_REQUEST);
let offered = [("get_weather", ¶ms)];
let (status, Json(body)) = build(
Forced::Named("nope"),
&specs(&offered),
ToolCallFormat::Qwen25,
)
.expect_err("no such tool");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"]["message"].as_str().unwrap().contains("nope"));
let hard = serde_json::json!({"allOf": [{"type": "object"}]});
let unconvertible = [("get_weather", &hard)];
let (status, Json(body)) =
build(Forced::Any, &specs(&unconvertible), ToolCallFormat::Qwen25)
.expect_err("allOf has no grammar");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("get_weather"),
"{body}"
);
}
#[test]
fn tools_with_colliding_rule_names_stay_distinct() {
let a = serde_json::json!({
"type": "object",
"properties": {"a": {"type": "string"}},
"required": ["a"],
"additionalProperties": false,
});
let b = serde_json::json!({
"type": "object",
"properties": {"b": {"type": "string"}},
"required": ["b"],
"additionalProperties": false,
});
let offered = [("do_it", &a), ("do.it", &b)];
let g = build(Forced::Any, &specs(&offered), ToolCallFormat::Qwen25).unwrap();
assert!(feed(
&g,
&[r#"<tool_call>{"name": "do_it", "arguments": {"a": "x"}}</tool_call>"#]
)
.unwrap());
assert!(
feed(&g, &[r#"<tool_call>{"name": "do.it", "arguments": {"a""#]).is_err(),
"the second tool must keep its own argument grammar"
);
}
}