mod elements;
mod harmony;
mod json;
mod pairs;
use serde_json::Value;
use ferrox_models::grammar::json_schema::GrammarBuilder;
use ferrox_models::grammar::LazyTriggers;
use super::{escape, internal, invalid, unsupported, ToolSpec};
use crate::policy::parser::ToolCallFormat;
use crate::ApiError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shape {
Json { array: bool },
Elements,
Harmony,
Pairs,
}
fn shape(format: ToolCallFormat) -> Result<Shape, ApiError> {
match format {
ToolCallFormat::Qwen25 | ToolCallFormat::Llama3 => Ok(Shape::Json { array: false }),
ToolCallFormat::Mistral => Ok(Shape::Json { array: true }),
ToolCallFormat::Qwen3Coder
| ToolCallFormat::Glm47
| ToolCallFormat::MiniMax
| ToolCallFormat::DeepSeekV32
| ToolCallFormat::MiniMaxM3 => Ok(Shape::Elements),
ToolCallFormat::GptOss => Ok(Shape::Harmony),
ToolCallFormat::Gemma4 => Ok(Shape::Pairs),
ToolCallFormat::MuseGlimmer => Err(refused(
format,
"a muse_glimmer call's boundary is not syntactic. The same <atem:function_calls> \
block is a call inside a channel addressed to a tool and prose inside one addressed \
to the user, so a grammar over the block alone would force text this server reads \
back as content -- and forcing the HEADER instead needs two things this seam does \
not have: which recipient name this checkpoint's template writes (the parser accepts \
any name that is not \"self\" or \"user\", so the format does not fix it), and how \
much of that header the rendered prompt already wrote, since a muse_glimmer prompt \
ends INSIDE one. Both are facts about a template, and no muse_glimmer checkpoint or \
template is on hand to read them off",
)),
}
}
pub(super) fn build_root(
builder: &mut GrammarBuilder,
format: ToolCallFormat,
tools: &[ToolSpec<'_>],
) -> Result<(String, LazyTriggers), ApiError> {
match shape(format)? {
Shape::Json { array } => json::json_root(builder, format, tools, array),
Shape::Elements => elements::elements_root(builder, format, tools),
Shape::Harmony => harmony::harmony_root(builder, tools),
Shape::Pairs => pairs::pairs_root(builder, format, tools),
}
}
pub(super) fn block(open: &str, body: &str, close: &str) -> String {
let mut root = format!(r#""{}" {body}"#, escape(open));
if !close.is_empty() {
root.push_str(&format!(r#" "{}""#, escape(close)));
}
root
}
pub(super) fn trigger(open: &str) -> Result<LazyTriggers, ApiError> {
LazyTriggers::new()
.with_word(open)
.map_err(|e| internal(format!("tool-call trigger does not compile: {e}")))
.map(LazyTriggers::mandatory)
}
pub(super) fn parameters<'a>(tool: &ToolSpec<'a>) -> &'a Value {
static EMPTY: std::sync::OnceLock<Value> = std::sync::OnceLock::new();
tool.parameters.unwrap_or_else(|| {
EMPTY.get_or_init(|| {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false,
})
})
})
}
pub(super) fn check_key(tool: &str, key: &str) -> Result<(), ApiError> {
let ok = !key.is_empty()
&& key.len() <= 64
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.');
if ok {
return Ok(());
}
Err(invalid(
format!(
"tool {tool:?} cannot be forced: its argument {key:?} is written into the wire format \
as a bare name, and this server accepts only names of 1..=64 characters from \
[A-Za-z0-9_.-] there"
),
"tools",
))
}
pub(super) fn object_expected(tool: &str) -> ApiError {
invalid(
format!(
"tool {tool:?} cannot be forced: this checkpoint's wire format writes a call's \
arguments as named members, so its \"parameters\" must be an object schema"
),
"tools",
)
}
pub(super) fn untyped(tool: &str, key: &str, why: &str) -> ApiError {
invalid(
format!(
"tool {tool:?} cannot be forced: its argument {key:?} cannot be given a value \
rule, because {why}"
),
"tools",
)
}
fn refused(format: ToolCallFormat, why: &str) -> ApiError {
unsupported(format!(
"tool_choice cannot be enforced for a {} checkpoint: {why}. Use tool_choice \"auto\", \
which asks for a call in the prompt instead of forcing one.",
format.as_str()
))
}