Skip to main content

minillmlib/
tools.rs

1//! Normalized tool / function calling types.
2//!
3//! These are provider-agnostic *intent*, not a wire shape (same principle as
4//! [`CompletionParameters`](crate::CompletionParameters)). Each provider's
5//! [`Provider::build_request`](crate::Provider::build_request) translates them to
6//! its own wire:
7//! - OpenAI-wire (OpenAI, OpenRouter, compatibles): `tools: [{"type":"function",
8//!   "function":{name, description, parameters, strict}}]`, `tool_choice:
9//!   "auto"|"none"|"required"|{"type":"function","function":{"name":...}}`, and a
10//!   top-level `parallel_tool_calls` bool.
11//! - Anthropic `/v1/messages`: `tools: [{name, description, input_schema,
12//!   strict}]`, `tool_choice: {"type":"auto"|"none"|"any"|"tool"}` with
13//!   `disable_parallel_tool_use` folded in.
14//!
15//! The response side is normalized the same way: every provider parses its wire
16//! into [`ToolCall`] (complete calls) and [`ToolCallDelta`] (streaming
17//! fragments), which [`ToolCallAccumulator`] assembles.
18
19use crate::error::{MiniLLMError, Result};
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22
23// =============================================================================
24// Request side: definitions + choice
25// =============================================================================
26
27/// A tool the model may call: a name, an optional description, and a JSON
28/// Schema for its arguments. Provider-agnostic; the provider emits its wire
29/// shape ([`to_openai_value`](Self::to_openai_value) /
30/// [`to_anthropic_value`](Self::to_anthropic_value)).
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct ToolDefinition {
33    /// The tool's name (what the model calls it by).
34    pub name: String,
35
36    /// What the tool does and when to use it. Strongly recommended: the model
37    /// decides when to call based on this text.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub description: Option<String>,
40
41    /// JSON Schema for the tool's arguments (an object schema with
42    /// `properties`/`required`). OpenAI-wire sends it as `parameters`,
43    /// Anthropic as `input_schema`.
44    pub parameters: serde_json::Value,
45
46    /// Ask the provider to guarantee schema conformance (OpenAI structured
47    /// outputs `strict`, Anthropic strict tool use). Omitted from the wire when
48    /// `None` (provider default).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub strict: Option<bool>,
51}
52
53impl ToolDefinition {
54    /// New tool definition from a name, description, and argument JSON Schema.
55    pub fn new(
56        name: impl Into<String>,
57        description: impl Into<String>,
58        parameters: serde_json::Value,
59    ) -> Self {
60        Self {
61            name: name.into(),
62            description: Some(description.into()),
63            parameters,
64            strict: None,
65        }
66    }
67
68    /// Ask the provider to enforce exact schema conformance on the arguments.
69    pub fn with_strict(mut self, strict: bool) -> Self {
70        self.strict = Some(strict);
71        self
72    }
73
74    /// OpenAI-wire shape: `{"type":"function","function":{...}}`.
75    pub fn to_openai_value(&self) -> serde_json::Value {
76        let mut function = serde_json::json!({
77            "name": self.name,
78            "parameters": self.parameters,
79        });
80        if let Some(desc) = &self.description {
81            function["description"] = serde_json::json!(desc);
82        }
83        if let Some(strict) = self.strict {
84            function["strict"] = serde_json::json!(strict);
85        }
86        serde_json::json!({ "type": "function", "function": function })
87    }
88
89    /// Anthropic `/v1/messages` shape: `{name, description, input_schema}`.
90    pub fn to_anthropic_value(&self) -> serde_json::Value {
91        let mut tool = serde_json::json!({
92            "name": self.name,
93            "input_schema": self.parameters,
94        });
95        if let Some(desc) = &self.description {
96            tool["description"] = serde_json::json!(desc);
97        }
98        if let Some(strict) = self.strict {
99            tool["strict"] = serde_json::json!(strict);
100        }
101        tool
102    }
103}
104
105/// How the model must treat the provided tools.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub enum ToolChoice {
108    /// The model decides whether to call a tool (the provider default when
109    /// tools are present).
110    Auto,
111    /// The model must NOT call any tool.
112    None,
113    /// The model must call at least one tool (OpenAI `"required"`, Anthropic
114    /// `{"type":"any"}`).
115    Required,
116    /// The model must call this specific tool (by name).
117    Tool(String),
118}
119
120impl ToolChoice {
121    /// OpenAI-wire `tool_choice` value.
122    pub fn to_openai_value(&self) -> serde_json::Value {
123        match self {
124            Self::Auto => serde_json::json!("auto"),
125            Self::None => serde_json::json!("none"),
126            Self::Required => serde_json::json!("required"),
127            Self::Tool(name) => serde_json::json!({
128                "type": "function",
129                "function": { "name": name },
130            }),
131        }
132    }
133
134    /// Anthropic `tool_choice` value. `disable_parallel_tool_use` is folded in
135    /// by the Anthropic request builder (it lives inside this object on that
136    /// wire), not here.
137    pub fn to_anthropic_value(&self) -> serde_json::Value {
138        match self {
139            Self::Auto => serde_json::json!({ "type": "auto" }),
140            Self::None => serde_json::json!({ "type": "none" }),
141            Self::Required => serde_json::json!({ "type": "any" }),
142            Self::Tool(name) => serde_json::json!({ "type": "tool", "name": name }),
143        }
144    }
145}
146
147// =============================================================================
148// Response side: complete calls + streaming deltas
149// =============================================================================
150
151/// A complete tool call made by the model, normalized across providers.
152///
153/// `arguments` is the RAW JSON TEXT of the call's arguments, exactly as the
154/// model produced it (OpenAI-wire sends a JSON string; Anthropic's `input`
155/// object is serialized to text on parse). Kept as text so a malformed
156/// model output is preserved verbatim rather than silently repaired; use
157/// [`arguments_json`](Self::arguments_json) to parse it, failing loudly.
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159pub struct ToolCall {
160    /// The provider's id for this call; echo it back in the tool-result message
161    /// ([`Message::tool`](crate::Message::tool)).
162    pub id: String,
163
164    /// The name of the tool being called.
165    pub name: String,
166
167    /// The call's arguments as raw JSON text.
168    pub arguments: String,
169}
170
171impl ToolCall {
172    /// New tool call from id, name, and raw JSON argument text.
173    pub fn new(
174        id: impl Into<String>,
175        name: impl Into<String>,
176        arguments: impl Into<String>,
177    ) -> Self {
178        Self {
179            id: id.into(),
180            name: name.into(),
181            arguments: arguments.into(),
182        }
183    }
184
185    /// Parse the raw argument text as JSON, failing loudly when the model
186    /// produced invalid JSON.
187    pub fn arguments_json(&self) -> Result<serde_json::Value> {
188        serde_json::from_str(&self.arguments).map_err(|e| {
189            MiniLLMError::InvalidParameter(format!(
190                "tool call '{}' ({}) carries invalid JSON arguments: {} (raw: {})",
191                self.name, self.id, e, self.arguments
192            ))
193        })
194    }
195
196    /// OpenAI-wire assistant-message entry:
197    /// `{"id","type":"function","function":{"name","arguments"}}` (arguments as
198    /// a JSON string, which is what that wire expects).
199    pub fn to_openai_value(&self) -> serde_json::Value {
200        serde_json::json!({
201            "id": self.id,
202            "type": "function",
203            "function": {
204                "name": self.name,
205                "arguments": self.arguments,
206            },
207        })
208    }
209
210    /// Anthropic assistant `tool_use` content block. Parses the raw argument
211    /// text (Anthropic's `input` is a JSON object, not a string), failing
212    /// loudly on invalid JSON.
213    pub fn to_anthropic_block(&self) -> Result<serde_json::Value> {
214        Ok(serde_json::json!({
215            "type": "tool_use",
216            "id": self.id,
217            "name": self.name,
218            "input": self.arguments_json()?,
219        }))
220    }
221}
222
223/// One streaming fragment of a tool call, normalized across providers.
224///
225/// OpenAI-wire streams `delta.tool_calls` entries (first delta carries
226/// id/name, later ones argument fragments); Anthropic streams a
227/// `content_block_start` (id/name) then `input_json_delta` fragments. Both
228/// key fragments by a block/call `index`; indices may be sparse (Anthropic's
229/// index space is shared with text blocks), so the accumulator maps by index
230/// rather than assuming contiguity.
231#[derive(Debug, Clone, PartialEq, Default)]
232pub struct ToolCallDelta {
233    /// The wire's index for this call (de-multiplexes parallel calls).
234    pub index: u64,
235
236    /// The call id (usually only on the first fragment).
237    pub id: Option<String>,
238
239    /// The tool name (usually only on the first fragment).
240    pub name: Option<String>,
241
242    /// A fragment of the raw JSON argument text, to be concatenated in order.
243    pub arguments_fragment: Option<String>,
244}
245
246/// Assembles [`ToolCallDelta`] fragments into complete [`ToolCall`]s.
247///
248/// Slots are keyed by the wire index in an ordered map, so sparse or
249/// interleaved indices are handled and a hostile index can never size an
250/// allocation.
251#[derive(Debug, Default)]
252pub struct ToolCallAccumulator {
253    slots: BTreeMap<u64, PartialToolCall>,
254}
255
256#[derive(Debug, Default)]
257struct PartialToolCall {
258    id: Option<String>,
259    name: Option<String>,
260    arguments: String,
261}
262
263impl ToolCallAccumulator {
264    /// Fold a batch of deltas into the accumulator.
265    pub fn ingest(&mut self, deltas: &[ToolCallDelta]) {
266        for delta in deltas {
267            let slot = self.slots.entry(delta.index).or_default();
268            if let Some(id) = &delta.id {
269                slot.id = Some(id.clone());
270            }
271            if let Some(name) = &delta.name {
272                slot.name = Some(name.clone());
273            }
274            if let Some(frag) = &delta.arguments_fragment {
275                slot.arguments.push_str(frag);
276            }
277        }
278    }
279
280    /// Whether nothing has been accumulated.
281    pub fn is_empty(&self) -> bool {
282        self.slots.is_empty()
283    }
284
285    /// Assemble the completed calls, in index order. A slot that never received
286    /// an id or name (a stream cancelled mid-call, or a malformed wire) cannot
287    /// be a usable call; it is dropped with a loud warning rather than
288    /// fabricated.
289    pub fn finish(&self) -> Vec<ToolCall> {
290        self.slots
291            .iter()
292            .filter_map(|(index, slot)| match (&slot.id, &slot.name) {
293                (Some(id), Some(name)) => {
294                    Some(ToolCall::new(id.clone(), name.clone(), slot.arguments.clone()))
295                }
296                _ => {
297                    tracing::warn!(
298                        index,
299                        has_id = slot.id.is_some(),
300                        has_name = slot.name.is_some(),
301                        "incomplete tool call fragment dropped (stream cancelled mid-call or malformed wire)"
302                    );
303                    None
304                }
305            })
306            .collect()
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn weather_tool() -> ToolDefinition {
315        ToolDefinition::new(
316            "get_weather",
317            "Get the current weather for a city",
318            serde_json::json!({
319                "type": "object",
320                "properties": { "city": { "type": "string" } },
321                "required": ["city"],
322            }),
323        )
324    }
325
326    #[test]
327    fn definition_openai_wire_shape() {
328        let v = weather_tool().with_strict(true).to_openai_value();
329        assert_eq!(v["type"], "function");
330        assert_eq!(v["function"]["name"], "get_weather");
331        assert_eq!(
332            v["function"]["description"],
333            "Get the current weather for a city"
334        );
335        assert_eq!(v["function"]["parameters"]["type"], "object");
336        assert_eq!(v["function"]["strict"], true);
337    }
338
339    #[test]
340    fn definition_anthropic_wire_shape() {
341        let v = weather_tool().to_anthropic_value();
342        assert_eq!(v["name"], "get_weather");
343        assert_eq!(v["input_schema"]["type"], "object");
344        assert!(v.get("strict").is_none(), "strict omitted when unset");
345        // OpenAI-only keys must not leak.
346        assert!(v.get("type").is_none());
347        assert!(v.get("parameters").is_none());
348    }
349
350    #[test]
351    fn choice_openai_wire_values() {
352        assert_eq!(ToolChoice::Auto.to_openai_value(), "auto");
353        assert_eq!(ToolChoice::None.to_openai_value(), "none");
354        assert_eq!(ToolChoice::Required.to_openai_value(), "required");
355        let forced = ToolChoice::Tool("get_weather".into()).to_openai_value();
356        assert_eq!(forced["type"], "function");
357        assert_eq!(forced["function"]["name"], "get_weather");
358    }
359
360    #[test]
361    fn choice_anthropic_wire_values() {
362        assert_eq!(ToolChoice::Auto.to_anthropic_value()["type"], "auto");
363        assert_eq!(ToolChoice::None.to_anthropic_value()["type"], "none");
364        // OpenAI "required" is Anthropic "any".
365        assert_eq!(ToolChoice::Required.to_anthropic_value()["type"], "any");
366        let forced = ToolChoice::Tool("get_weather".into()).to_anthropic_value();
367        assert_eq!(forced["type"], "tool");
368        assert_eq!(forced["name"], "get_weather");
369    }
370
371    #[test]
372    fn call_arguments_json_parses_or_fails_loudly() {
373        let ok = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#);
374        assert_eq!(ok.arguments_json().unwrap()["city"], "Paris");
375        let bad = ToolCall::new("c2", "get_weather", "{not json");
376        assert!(bad.arguments_json().is_err());
377    }
378
379    #[test]
380    fn call_openai_wire_keeps_arguments_as_string() {
381        let v = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#).to_openai_value();
382        assert_eq!(v["id"], "c1");
383        assert_eq!(v["type"], "function");
384        assert_eq!(v["function"]["name"], "get_weather");
385        assert_eq!(v["function"]["arguments"], r#"{"city":"Paris"}"#);
386        assert!(v["function"]["arguments"].is_string());
387    }
388
389    #[test]
390    fn call_anthropic_block_parses_arguments_to_object() {
391        let b = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#)
392            .to_anthropic_block()
393            .unwrap();
394        assert_eq!(b["type"], "tool_use");
395        assert_eq!(b["id"], "c1");
396        assert_eq!(b["name"], "get_weather");
397        assert_eq!(b["input"]["city"], "Paris");
398        assert!(b["input"].is_object(), "input is an object, not a string");
399        // Invalid argument text fails loudly instead of shipping garbage.
400        assert!(ToolCall::new("c2", "t", "{bad")
401            .to_anthropic_block()
402            .is_err());
403    }
404
405    #[test]
406    fn accumulator_assembles_fragments_by_index() {
407        let mut acc = ToolCallAccumulator::default();
408        acc.ingest(&[ToolCallDelta {
409            index: 0,
410            id: Some("c0".into()),
411            name: Some("search".into()),
412            arguments_fragment: Some("{\"q\":".into()),
413        }]);
414        acc.ingest(&[ToolCallDelta {
415            index: 0,
416            arguments_fragment: Some("\"rust\"}".into()),
417            ..Default::default()
418        }]);
419        let calls = acc.finish();
420        assert_eq!(calls.len(), 1);
421        assert_eq!(calls[0].id, "c0");
422        assert_eq!(calls[0].name, "search");
423        assert_eq!(calls[0].arguments, r#"{"q":"rust"}"#);
424    }
425
426    #[test]
427    fn accumulator_handles_sparse_and_interleaved_indices() {
428        // Anthropic shares the index space with text blocks: a tool call can
429        // start at index 1 (or higher) with no slot 0, and parallel calls
430        // interleave. Order of output follows index order.
431        let mut acc = ToolCallAccumulator::default();
432        acc.ingest(&[
433            ToolCallDelta {
434                index: 3,
435                id: Some("c3".into()),
436                name: Some("b".into()),
437                ..Default::default()
438            },
439            ToolCallDelta {
440                index: 1,
441                id: Some("c1".into()),
442                name: Some("a".into()),
443                ..Default::default()
444            },
445        ]);
446        acc.ingest(&[
447            ToolCallDelta {
448                index: 1,
449                arguments_fragment: Some("{}".into()),
450                ..Default::default()
451            },
452            ToolCallDelta {
453                index: 3,
454                arguments_fragment: Some("{}".into()),
455                ..Default::default()
456            },
457        ]);
458        let calls = acc.finish();
459        assert_eq!(calls.len(), 2);
460        assert_eq!(calls[0].id, "c1", "index order preserved");
461        assert_eq!(calls[1].id, "c3");
462    }
463
464    #[test]
465    fn accumulator_drops_incomplete_slots_and_never_allocates_by_index() {
466        let mut acc = ToolCallAccumulator::default();
467        // A hostile/huge index is just a map key, never an allocation size.
468        acc.ingest(&[ToolCallDelta {
469            index: 4_000_000_000,
470            arguments_fragment: Some("junk".into()),
471            ..Default::default()
472        }]);
473        // No id/name ever arrived: the slot is unusable and dropped (loudly).
474        assert!(acc.finish().is_empty());
475    }
476
477    #[test]
478    fn tool_call_round_trips_serde() {
479        // Message/node persistence serializes ToolCall; the round trip is the
480        // wire-shape contract for saved trees.
481        let call = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#);
482        let json = serde_json::to_string(&call).unwrap();
483        let back: ToolCall = serde_json::from_str(&json).unwrap();
484        assert_eq!(back, call);
485    }
486}