mod args;
pub use args::{ArgumentStream, FieldHandle};
use crate::error::{MiniLLMError, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
impl ToolDefinition {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
) -> Self {
Self {
name: name.into(),
description: Some(description.into()),
parameters,
strict: None,
}
}
pub fn with_strict(mut self, strict: bool) -> Self {
self.strict = Some(strict);
self
}
pub fn to_openai_value(&self) -> serde_json::Value {
let mut function = serde_json::json!({
"name": self.name,
"parameters": self.parameters,
});
if let Some(desc) = &self.description {
function["description"] = serde_json::json!(desc);
}
if let Some(strict) = self.strict {
function["strict"] = serde_json::json!(strict);
}
serde_json::json!({ "type": "function", "function": function })
}
pub fn to_anthropic_value(&self) -> serde_json::Value {
let mut tool = serde_json::json!({
"name": self.name,
"input_schema": self.parameters,
});
if let Some(desc) = &self.description {
tool["description"] = serde_json::json!(desc);
}
if let Some(strict) = self.strict {
tool["strict"] = serde_json::json!(strict);
}
tool
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolChoice {
Auto,
None,
Required,
Tool(String),
}
impl ToolChoice {
pub fn to_openai_value(&self) -> serde_json::Value {
match self {
Self::Auto => serde_json::json!("auto"),
Self::None => serde_json::json!("none"),
Self::Required => serde_json::json!("required"),
Self::Tool(name) => serde_json::json!({
"type": "function",
"function": { "name": name },
}),
}
}
pub fn to_anthropic_value(&self) -> serde_json::Value {
match self {
Self::Auto => serde_json::json!({ "type": "auto" }),
Self::None => serde_json::json!({ "type": "none" }),
Self::Required => serde_json::json!({ "type": "any" }),
Self::Tool(name) => serde_json::json!({ "type": "tool", "name": name }),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
impl ToolCall {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
arguments: impl Into<String>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
arguments: arguments.into(),
}
}
pub fn arguments_json(&self) -> Result<serde_json::Value> {
serde_json::from_str(&self.arguments).map_err(|e| {
MiniLLMError::InvalidParameter(format!(
"tool call '{}' ({}) carries invalid JSON arguments: {} (raw: {})",
self.name, self.id, e, self.arguments
))
})
}
pub fn arguments_json_repaired(&self) -> Result<serde_json::Value> {
crate::utils::extract_json_value(&self.arguments).map_err(|e| {
MiniLLMError::InvalidParameter(format!(
"tool call '{}' ({}) carries unrepairable JSON arguments: {} (raw: {})",
self.name, self.id, e, self.arguments
))
})
}
pub fn to_openai_value(&self) -> serde_json::Value {
serde_json::json!({
"id": self.id,
"type": "function",
"function": {
"name": self.name,
"arguments": self.arguments,
},
})
}
pub fn to_anthropic_block(&self) -> Result<serde_json::Value> {
Ok(serde_json::json!({
"type": "tool_use",
"id": self.id,
"name": self.name,
"input": self.arguments_json()?,
}))
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ToolCallDelta {
pub index: u64,
pub id: Option<String>,
pub name: Option<String>,
pub arguments_fragment: Option<String>,
}
#[derive(Debug, Default)]
pub struct ToolCallAccumulator {
slots: BTreeMap<u64, PartialToolCall>,
}
#[derive(Debug, Default)]
struct PartialToolCall {
id: Option<String>,
name: Option<String>,
arguments: String,
}
impl ToolCallAccumulator {
pub fn ingest(&mut self, deltas: &[ToolCallDelta]) {
for delta in deltas {
let slot = self.slots.entry(delta.index).or_default();
if let Some(id) = &delta.id {
slot.id = Some(id.clone());
}
if let Some(name) = &delta.name {
slot.name = Some(name.clone());
}
if let Some(frag) = &delta.arguments_fragment {
slot.arguments.push_str(frag);
}
}
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
pub fn finish(&self) -> Vec<ToolCall> {
self.slots
.iter()
.filter_map(|(index, slot)| match (&slot.id, &slot.name) {
(Some(id), Some(name)) => {
Some(ToolCall::new(id.clone(), name.clone(), slot.arguments.clone()))
}
_ => {
tracing::warn!(
index,
has_id = slot.id.is_some(),
has_name = slot.name.is_some(),
"incomplete tool call fragment dropped (stream cancelled mid-call or malformed wire)"
);
None
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn weather_tool() -> ToolDefinition {
ToolDefinition::new(
"get_weather",
"Get the current weather for a city",
serde_json::json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
}),
)
}
#[test]
fn definition_openai_wire_shape() {
let v = weather_tool().with_strict(true).to_openai_value();
assert_eq!(v["type"], "function");
assert_eq!(v["function"]["name"], "get_weather");
assert_eq!(
v["function"]["description"],
"Get the current weather for a city"
);
assert_eq!(v["function"]["parameters"]["type"], "object");
assert_eq!(v["function"]["strict"], true);
}
#[test]
fn definition_anthropic_wire_shape() {
let v = weather_tool().to_anthropic_value();
assert_eq!(v["name"], "get_weather");
assert_eq!(v["input_schema"]["type"], "object");
assert!(v.get("strict").is_none(), "strict omitted when unset");
assert!(v.get("type").is_none());
assert!(v.get("parameters").is_none());
}
#[test]
fn choice_openai_wire_values() {
assert_eq!(ToolChoice::Auto.to_openai_value(), "auto");
assert_eq!(ToolChoice::None.to_openai_value(), "none");
assert_eq!(ToolChoice::Required.to_openai_value(), "required");
let forced = ToolChoice::Tool("get_weather".into()).to_openai_value();
assert_eq!(forced["type"], "function");
assert_eq!(forced["function"]["name"], "get_weather");
}
#[test]
fn choice_anthropic_wire_values() {
assert_eq!(ToolChoice::Auto.to_anthropic_value()["type"], "auto");
assert_eq!(ToolChoice::None.to_anthropic_value()["type"], "none");
assert_eq!(ToolChoice::Required.to_anthropic_value()["type"], "any");
let forced = ToolChoice::Tool("get_weather".into()).to_anthropic_value();
assert_eq!(forced["type"], "tool");
assert_eq!(forced["name"], "get_weather");
}
#[test]
fn call_arguments_json_parses_or_fails_loudly() {
let ok = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#);
assert_eq!(ok.arguments_json().unwrap()["city"], "Paris");
let bad = ToolCall::new("c2", "get_weather", "{not json");
assert!(bad.arguments_json().is_err());
}
#[test]
fn call_openai_wire_keeps_arguments_as_string() {
let v = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#).to_openai_value();
assert_eq!(v["id"], "c1");
assert_eq!(v["type"], "function");
assert_eq!(v["function"]["name"], "get_weather");
assert_eq!(v["function"]["arguments"], r#"{"city":"Paris"}"#);
assert!(v["function"]["arguments"].is_string());
}
#[test]
fn call_anthropic_block_parses_arguments_to_object() {
let b = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#)
.to_anthropic_block()
.unwrap();
assert_eq!(b["type"], "tool_use");
assert_eq!(b["id"], "c1");
assert_eq!(b["name"], "get_weather");
assert_eq!(b["input"]["city"], "Paris");
assert!(b["input"].is_object(), "input is an object, not a string");
assert!(ToolCall::new("c2", "t", "{bad")
.to_anthropic_block()
.is_err());
}
#[test]
fn accumulator_assembles_fragments_by_index() {
let mut acc = ToolCallAccumulator::default();
acc.ingest(&[ToolCallDelta {
index: 0,
id: Some("c0".into()),
name: Some("search".into()),
arguments_fragment: Some("{\"q\":".into()),
}]);
acc.ingest(&[ToolCallDelta {
index: 0,
arguments_fragment: Some("\"rust\"}".into()),
..Default::default()
}]);
let calls = acc.finish();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "c0");
assert_eq!(calls[0].name, "search");
assert_eq!(calls[0].arguments, r#"{"q":"rust"}"#);
}
#[test]
fn accumulator_handles_sparse_and_interleaved_indices() {
let mut acc = ToolCallAccumulator::default();
acc.ingest(&[
ToolCallDelta {
index: 3,
id: Some("c3".into()),
name: Some("b".into()),
..Default::default()
},
ToolCallDelta {
index: 1,
id: Some("c1".into()),
name: Some("a".into()),
..Default::default()
},
]);
acc.ingest(&[
ToolCallDelta {
index: 1,
arguments_fragment: Some("{}".into()),
..Default::default()
},
ToolCallDelta {
index: 3,
arguments_fragment: Some("{}".into()),
..Default::default()
},
]);
let calls = acc.finish();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].id, "c1", "index order preserved");
assert_eq!(calls[1].id, "c3");
}
#[test]
fn accumulator_drops_incomplete_slots_and_never_allocates_by_index() {
let mut acc = ToolCallAccumulator::default();
acc.ingest(&[ToolCallDelta {
index: 4_000_000_000,
arguments_fragment: Some("junk".into()),
..Default::default()
}]);
assert!(acc.finish().is_empty());
}
#[test]
fn tool_call_round_trips_serde() {
let call = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#);
let json = serde_json::to_string(&call).unwrap();
let back: ToolCall = serde_json::from_str(&json).unwrap();
assert_eq!(back, call);
}
}