Skip to main content

vtcode_llm/providers/
tool_format.rs

1//! Provider tool formatters.
2//!
3//! Each provider (or family) shapes `ToolDefinition` into the JSON it expects on the
4//! wire. Historically, this logic was scattered across the request builders of each
5//! provider crate, making it hard to verify cross-provider invariants (e.g. that
6//! `defer_loading`, `strict`, `input_examples`, `allowed_callers` are preserved when
7//! they apply). This module introduces a single trait that all providers can
8//! implement, plus ready-made implementations for the most common cases.
9//!
10//! The trait is intentionally additive: existing callers can keep using the
11//! per-provider helpers (e.g. `serialize_tools_openai_format`,
12//! `anthropic::request_builder::tools::build_tools`). New callers should reach for
13//! `formatter_for(provider_id, model)` instead.
14//!
15//! See *The Hitchhiker's Guide to Agentic AI* §18.4.1 for the underlying model
16//! (separate OpenAI / Anthropic / Gemini / MCP wire shapes) and §18.4.2 for the
17//! selection / routing concerns that sit above this layer.
18
19use serde_json::Value;
20
21use crate::provider::{LLMError, ToolDefinition};
22
23/// Identifies a provider family for the purpose of tool formatting.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum ProviderFamily {
26    /// OpenAI Chat Completions API (function-calling shape).
27    OpenAIChat,
28    /// OpenAI Responses API (hosted tools, apply_patch, shell, custom, grammar).
29    OpenAIResponses,
30    /// Anthropic Messages API (input_schema, native hosted tools, advanced-tool-use).
31    Anthropic,
32    /// Google Gemini generateContent API (function declarations + native hosted tools).
33    Gemini,
34    /// Generic OpenAI-compatible Chat Completions API (DeepSeek, ZAI, Moonshot, …).
35    OpenAICompatible,
36}
37
38impl ProviderFamily {
39    /// Resolve a provider family from a canonical provider identifier (e.g. "openai",
40    /// "anthropic", "gemini", "deepseek"). Falls back to `OpenAICompatible` for any
41    /// unknown identifier — most providers in this class expose the same function
42    /// calling shape.
43    #[must_use]
44    fn from_provider_id(provider_id: &str) -> Self {
45        match provider_id.to_ascii_lowercase().as_str() {
46            "openai" => Self::OpenAIChat,
47            "openai-responses" | "openai_responses" => Self::OpenAIResponses,
48            "anthropic" | "claude" => Self::Anthropic,
49            "gemini" | "google" | "google-gemini" => Self::Gemini,
50            _ => Self::OpenAICompatible,
51        }
52    }
53}
54
55/// Trait implemented by every provider tool formatter.
56///
57/// Implementations are expected to be stateless. Constructors live in
58/// `formatters` below.
59pub trait ProviderToolFormatter: Send + Sync {
60    /// Family this formatter belongs to (for diagnostics).
61    fn family(&self) -> ProviderFamily;
62
63    /// Provider extensions preserved by this formatter. Used by callers that want to
64    /// validate that a particular extension (e.g. `defer_loading`) is supported before
65    /// passing a tool through.
66    fn supported_extensions(&self) -> &'static [&'static str];
67
68    /// Returns true when this formatter accepts the given tool type unchanged.
69    fn supports(&self, tool: &ToolDefinition) -> bool;
70
71    /// Format a slice of tool definitions into the wire-shape this provider expects.
72    ///
73    /// Returns `None` when the slice is empty (mirrors the existing per-provider
74    /// helpers).
75    fn format_tools(&self, tools: &[ToolDefinition], model: &str) -> Result<Option<Value>, LLMError>;
76}
77
78/// Anthropic formatter — extracts logic from
79/// `crates/codegen/vtcode-llm/src/providers/anthropic/request_builder/tools.rs::build_tools`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
81pub struct AnthropicFormatter;
82
83impl ProviderToolFormatter for AnthropicFormatter {
84    fn family(&self) -> ProviderFamily {
85        ProviderFamily::Anthropic
86    }
87
88    fn supported_extensions(&self) -> &'static [&'static str] {
89        &[
90            "input_examples",
91            "strict",
92            "allowed_callers",
93            "defer_loading",
94            "web_search_options",
95            "tool_search",
96            "code_execution",
97            "memory",
98        ]
99    }
100
101    fn supports(&self, tool: &ToolDefinition) -> bool {
102        tool.is_tool_search()
103            || tool.is_anthropic_web_search()
104            || tool.is_anthropic_code_execution()
105            || tool.is_anthropic_memory_tool()
106            || tool.function.is_some()
107    }
108
109    fn format_tools(&self, tools: &[ToolDefinition], _model: &str) -> Result<Option<Value>, LLMError> {
110        // Delegate to the existing build helper so we never regress the wire shape.
111        // The function is wired through `crate::providers::anthropic::request_builder::tools`
112        // to keep Anthropic-specific knowledge in one place.
113        super::anthropic::request_builder::tools::build_tools_via_formatter(tools)
114    }
115}
116
117/// OpenAI Chat Completions formatter — extracts logic from
118/// `crates/codegen/vtcode-llm/src/providers/common.rs::serialize_tools_openai_format`.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120pub struct OpenAIChatFormatter;
121
122impl ProviderToolFormatter for OpenAIChatFormatter {
123    fn family(&self) -> ProviderFamily {
124        ProviderFamily::OpenAIChat
125    }
126
127    fn supported_extensions(&self) -> &'static [&'static str] {
128        &["function_only"]
129    }
130
131    fn supports(&self, tool: &ToolDefinition) -> bool {
132        tool.tool_type == "function" || tool.tool_type == "web_search"
133    }
134
135    fn format_tools(&self, tools: &[ToolDefinition], _model: &str) -> Result<Option<Value>, LLMError> {
136        Ok(super::common::serialize_tools_openai_format(tools).map(Value::Array))
137    }
138}
139
140/// OpenAI Responses API formatter — model-aware, preserves `defer_loading`.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
142pub struct OpenAIResponsesFormatter;
143
144impl ProviderToolFormatter for OpenAIResponsesFormatter {
145    fn family(&self) -> ProviderFamily {
146        ProviderFamily::OpenAIResponses
147    }
148
149    fn supported_extensions(&self) -> &'static [&'static str] {
150        &[
151            "defer_loading",
152            "shell",
153            "apply_patch",
154            "custom",
155            "grammar",
156            "tool_search",
157            "hosted_web_search",
158            "hosted_file_search",
159            "hosted_mcp",
160        ]
161    }
162
163    fn supports(&self, _tool: &ToolDefinition) -> bool {
164        // The Responses API explicitly handles every variant of `ToolDefinition` —
165        // there's no tool type that it rejects outright.
166        true
167    }
168
169    fn format_tools(&self, tools: &[ToolDefinition], _model: &str) -> Result<Option<Value>, LLMError> {
170        // Defer to the existing per-provider helper to keep the wire shape stable.
171        Ok(super::openai::tool_serialization::serialize_tools_for_responses(tools, None))
172    }
173}
174
175/// Gemini formatter — uses `function_declarations` plus native hosted tool shapes.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
177pub struct GeminiFormatter;
178
179impl ProviderToolFormatter for GeminiFormatter {
180    fn family(&self) -> ProviderFamily {
181        ProviderFamily::Gemini
182    }
183
184    fn supported_extensions(&self) -> &'static [&'static str] {
185        &[
186            "google_search",
187            "google_maps",
188            "url_context",
189            "code_execution",
190            "function_declarations",
191        ]
192    }
193
194    fn supports(&self, tool: &ToolDefinition) -> bool {
195        matches!(
196            tool.tool_type.as_str(),
197            "function" | "google_search" | "google_maps" | "url_context" | "code_execution"
198        ) || tool.function.is_some()
199    }
200
201    fn format_tools(&self, tools: &[ToolDefinition], _model: &str) -> Result<Option<Value>, LLMError> {
202        // Delegate to the existing helper so the wire shape stays in lockstep with
203        // what the Gemini request builder already does today.
204        super::gemini::helpers::serialize_gemini_tools(tools)
205    }
206}
207
208/// Generic OpenAI-compatible formatter. Unlike `OpenAIChatFormatter`, this one is
209/// intentionally conservative: it only emits function-shaped tools and silently drops
210/// hosted / native tool types because most compatible providers don't support them.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct OpenAICompatibleFormatter;
213
214impl ProviderToolFormatter for OpenAICompatibleFormatter {
215    fn family(&self) -> ProviderFamily {
216        ProviderFamily::OpenAICompatible
217    }
218
219    fn supported_extensions(&self) -> &'static [&'static str] {
220        &["function_only"]
221    }
222
223    fn supports(&self, tool: &ToolDefinition) -> bool {
224        tool.tool_type == "function" || tool.tool_type == "web_search"
225    }
226
227    fn format_tools(&self, tools: &[ToolDefinition], _model: &str) -> Result<Option<Value>, LLMError> {
228        Ok(super::common::serialize_tools_openai_format(tools).map(Value::Array))
229    }
230}
231
232/// Static-dispatch formatter covering every provider family.
233///
234/// All formatter implementations are stateless zero-sized types, so heap-boxing
235/// one behind `Box<dyn ProviderToolFormatter>` buys nothing: the `Box` still
236/// allocates and every call pays a vtable lookup, while the wide pointer
237/// itself is 16 bytes (data + vtable). This enum holds the same ZSTs inline —
238/// one discriminant byte, no allocation — and its `ProviderToolFormatter` impl
239/// matches on the variant, giving the compiler a static call target per family.
240///
241/// Prefer [`formatter`] (which returns this enum) in new code. The
242/// `Box<dyn ProviderToolFormatter>` constructors below are kept for API
243/// compatibility and delegate to this enum internally.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
245pub enum ProviderFormatter {
246    /// OpenAI Chat Completions API shape.
247    OpenAIChat(OpenAIChatFormatter),
248    /// OpenAI Responses API shape.
249    OpenAIResponses(OpenAIResponsesFormatter),
250    /// Anthropic Messages API shape.
251    Anthropic(AnthropicFormatter),
252    /// Google Gemini `generateContent` API shape.
253    Gemini(GeminiFormatter),
254    /// Generic OpenAI-compatible Chat Completions shape.
255    OpenAICompatible(OpenAICompatibleFormatter),
256}
257
258impl ProviderToolFormatter for ProviderFormatter {
259    fn family(&self) -> ProviderFamily {
260        match self {
261            Self::OpenAIChat(_) => ProviderFamily::OpenAIChat,
262            Self::OpenAIResponses(_) => ProviderFamily::OpenAIResponses,
263            Self::Anthropic(_) => ProviderFamily::Anthropic,
264            Self::Gemini(_) => ProviderFamily::Gemini,
265            Self::OpenAICompatible(_) => ProviderFamily::OpenAICompatible,
266        }
267    }
268
269    fn supported_extensions(&self) -> &'static [&'static str] {
270        match self {
271            Self::OpenAIChat(f) => f.supported_extensions(),
272            Self::OpenAIResponses(f) => f.supported_extensions(),
273            Self::Anthropic(f) => f.supported_extensions(),
274            Self::Gemini(f) => f.supported_extensions(),
275            Self::OpenAICompatible(f) => f.supported_extensions(),
276        }
277    }
278
279    fn supports(&self, tool: &ToolDefinition) -> bool {
280        match self {
281            Self::OpenAIChat(f) => f.supports(tool),
282            Self::OpenAIResponses(f) => f.supports(tool),
283            Self::Anthropic(f) => f.supports(tool),
284            Self::Gemini(f) => f.supports(tool),
285            Self::OpenAICompatible(f) => f.supports(tool),
286        }
287    }
288
289    fn format_tools(&self, tools: &[ToolDefinition], model: &str) -> Result<Option<Value>, LLMError> {
290        match self {
291            Self::OpenAIChat(f) => f.format_tools(tools, model),
292            Self::OpenAIResponses(f) => f.format_tools(tools, model),
293            Self::Anthropic(f) => f.format_tools(tools, model),
294            Self::Gemini(f) => f.format_tools(tools, model),
295            Self::OpenAICompatible(f) => f.format_tools(tools, model),
296        }
297    }
298}
299
300/// Build a formatter for a given provider family without heap allocation or
301/// dynamic dispatch.
302///
303/// This is the preferred constructor: it returns [`ProviderFormatter`] by
304/// value (one byte) instead of a 16-byte `Box<dyn>` wide pointer.
305#[must_use]
306pub fn formatter(family: ProviderFamily) -> ProviderFormatter {
307    match family {
308        ProviderFamily::OpenAIChat => ProviderFormatter::OpenAIChat(OpenAIChatFormatter),
309        ProviderFamily::OpenAIResponses => ProviderFormatter::OpenAIResponses(OpenAIResponsesFormatter),
310        ProviderFamily::Anthropic => ProviderFormatter::Anthropic(AnthropicFormatter),
311        ProviderFamily::Gemini => ProviderFormatter::Gemini(GeminiFormatter),
312        ProviderFamily::OpenAICompatible => ProviderFormatter::OpenAICompatible(OpenAICompatibleFormatter),
313    }
314}
315
316/// Build a formatter for a given provider family.
317///
318/// Compatibility shim over [`formatter`]: boxes the static-dispatch enum for
319/// callers that need a trait object. New code should call [`formatter`]
320/// directly to avoid the heap allocation and vtable lookup.
321#[must_use]
322fn formatter_for_family(family: ProviderFamily) -> Box<dyn ProviderToolFormatter> {
323    Box::new(formatter(family))
324}
325
326/// Convenience: resolve a formatter from a provider identifier. This is the entry
327/// point the runloop should use when constructing an LLM request.
328#[must_use]
329fn formatter_for_provider(provider_id: &str) -> Box<dyn ProviderToolFormatter> {
330    formatter_for_family(ProviderFamily::from_provider_id(provider_id))
331}
332
333/// Convenience: resolve a formatter using the explicit `ProviderFamily`. Useful in
334/// tests or when the runloop already knows the family without parsing the provider
335/// string.
336#[must_use]
337pub fn formatter_for(family: ProviderFamily) -> Box<dyn ProviderToolFormatter> {
338    formatter_for_family(family)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use serde_json::json;
345
346    fn sample_function_tool() -> ToolDefinition {
347        ToolDefinition::function(
348            "search_docs".to_owned(),
349            "Search documentation".to_owned(),
350            json!({
351                "type": "object",
352                "properties": {
353                    "query": {"type": "string"}
354                },
355                "required": ["query"]
356            }),
357        )
358    }
359
360    #[test]
361    fn provider_family_resolution_handles_known_ids() {
362        assert_eq!(ProviderFamily::from_provider_id("openai"), ProviderFamily::OpenAIChat);
363        assert_eq!(ProviderFamily::from_provider_id("anthropic"), ProviderFamily::Anthropic);
364        assert_eq!(ProviderFamily::from_provider_id("gemini"), ProviderFamily::Gemini);
365        assert_eq!(ProviderFamily::from_provider_id("deepseek"), ProviderFamily::OpenAICompatible);
366        assert_eq!(ProviderFamily::from_provider_id("unknown"), ProviderFamily::OpenAICompatible);
367    }
368
369    #[test]
370    fn formatter_for_provider_returns_trait_object() {
371        let f = formatter_for_provider("anthropic");
372        assert_eq!(f.family(), ProviderFamily::Anthropic);
373
374        let f = formatter_for_provider("openai");
375        assert_eq!(f.family(), ProviderFamily::OpenAIChat);
376
377        let f = formatter_for_provider("deepseek");
378        assert_eq!(f.family(), ProviderFamily::OpenAICompatible);
379    }
380
381    #[test]
382    fn empty_tool_slice_formats_to_none() {
383        // The formatter contract mirrors existing helpers: an empty tool slice yields
384        // `None` so callers can omit the `"tools"` key from the wire payload.
385        let f = formatter_for_provider("anthropic");
386        assert!(
387            f.format_tools(&[], "claude-opus-4-7")
388                .expect("empty formatting should succeed")
389                .is_none()
390        );
391
392        let f = formatter_for_provider("deepseek");
393        assert!(
394            f.format_tools(&[], "deepseek-chat")
395                .expect("empty formatting should succeed")
396                .is_none()
397        );
398    }
399
400    #[test]
401    fn openai_compatible_formatter_silently_drops_extensions() {
402        // Build a tool with `defer_loading` and `strict` set; the OpenAI-compatible
403        // formatter must drop both (the serializer has nowhere to put them).
404        let tool = sample_function_tool().with_strict(true).with_defer_loading(true);
405
406        let f = formatter_for_provider("deepseek");
407        let value = f
408            .format_tools(std::slice::from_ref(&tool), "deepseek-chat")
409            .expect("formatter should serialize")
410            .expect("formatter should yield a value for a non-empty slice");
411        let arr = value.as_array().expect("expected array");
412        assert_eq!(arr.len(), 1);
413        let serialized = &arr[0];
414        assert!(serialized.get("defer_loading").is_none(), "openai-compatible formatter must drop defer_loading");
415        assert!(serialized.get("strict").is_none(), "openai-compatible formatter must drop strict");
416    }
417
418    #[test]
419    fn anthropic_formatter_preserves_function_extension_fields() {
420        // `strict` and `input_examples` are explicitly preserved by the Anthropic
421        // path; this guards against a regression that drops them on the floor.
422        let tool = sample_function_tool().with_strict(true).with_input_examples(vec![json!({
423            "input": "Find Rust docs",
424            "tool_use": { "query": "rust" }
425        })]);
426
427        let f = formatter_for_provider("anthropic");
428        assert!(f.supports(&tool));
429
430        let value = f
431            .format_tools(std::slice::from_ref(&tool), "claude-opus-4-7")
432            .expect("formatter should yield a value");
433        let serialized = value.expect("non-empty tool should serialize").to_string();
434        assert!(serialized.contains("strict"), "anthropic wire payload missing strict: {serialized}");
435        assert!(serialized.contains("input_examples"), "anthropic wire payload missing input_examples: {serialized}");
436    }
437
438    #[test]
439    fn static_formatter_matches_boxed_formatter_for_every_family() {
440        // The static-dispatch enum must agree with the boxed trait-object path
441        // on family identity and supported extensions.
442        for family in [
443            ProviderFamily::OpenAIChat,
444            ProviderFamily::OpenAIResponses,
445            ProviderFamily::Anthropic,
446            ProviderFamily::Gemini,
447            ProviderFamily::OpenAICompatible,
448        ] {
449            let statically = formatter(family);
450            let boxed = formatter_for_family(family);
451            assert_eq!(statically.family(), family);
452            assert_eq!(statically.family(), boxed.family());
453            assert_eq!(statically.supported_extensions(), boxed.supported_extensions());
454        }
455    }
456
457    #[test]
458    fn static_formatter_has_no_wide_pointer_overhead() {
459        // `Box<dyn ProviderToolFormatter>` is a wide pointer: 16 bytes on
460        // 64-bit (data + vtable). The enum holds ZSTs inline, so it must stay
461        // a single discriminant byte with no heap allocation.
462        assert_eq!(size_of::<ProviderFormatter>(), 1);
463        assert_eq!(size_of::<Box<dyn ProviderToolFormatter>>(), 2 * size_of::<usize>());
464    }
465
466    #[test]
467    fn formatter_extensions_are_non_empty_for_every_family() {
468        // Each family exposes a non-empty extension list so callers can introspect
469        // what they support before emitting a request.
470        for family in [
471            ProviderFamily::OpenAIChat,
472            ProviderFamily::OpenAIResponses,
473            ProviderFamily::Anthropic,
474            ProviderFamily::Gemini,
475            ProviderFamily::OpenAICompatible,
476        ] {
477            let f = formatter_for_family(family);
478            assert!(!f.supported_extensions().is_empty(), "{family:?} must report at least one extension");
479        }
480    }
481}