Function Calling Deep Dive — OpenAI, Anthropic, Gemini
OpenAI, Anthropic, and Gemini all settled on the same tool-call loop and then went their own ways on the details. OpenAI uses `tools` and `tool_calls`. Anthropic uses `tool_use` and `tool_result` blocks. Gemini uses `functionDeclarations` and per-call ids. This lesson puts the three side by side so code written for one provider survives a port to another.
Learning Objectives
- State the three shape differences between OpenAI, Anthropic, and Gemini function-calling payloads (declaration, call, result).
- Translate one tool declaration across all three provider formats and predict where strict-mode constraints will differ.
- Use `tool_choice` in each provider to force, forbid, or auto-pick tool calls.
- Know the per-provider hard limits (tool count, schema depth, argument length) and the error signatures each one emits when limits are violated.
The Problem
The shape of a function-calling request depends on the provider. Three examples from 2026 production stacks:
OpenAI Chat Completions / Responses API. You pass tools: [{type: "function", function: {name, description, parameters, strict}}]. The response carries choices[0].message.tool_calls: [{id, type: "function", function: {name, arguments}}], and arguments is a JSON string you have to parse. Strict mode (strict: true) enforces schema compliance with constrained decoding.
Anthropic Messages API. You pass tools: [{name, description, input_schema}]. The response comes back as content: [{type: "text"}, {type: "tool_use", id, name, input}]. input is already an object, not a string. You answer with a new user message holding a {type: "tool_result", tool_use_id, content} block.
Google Gemini API. You pass tools: [{functionDeclarations: [{name, description, parameters}]}], nested under functionDeclarations. The response arrives as candidates[0].content.parts: [{functionCall: {name, args, id}}], where id is unique from Gemini 3 on so parallel calls can be correlated. You answer with {functionResponse: {name, id, response}}.
Same loop, different field names, different nesting, different string-versus-object conventions, different correlation mechanisms. A team that writes a weather agent on OpenAI pays two days to port it to Anthropic and another day for Gemini, and all of it is plumbing.
This lesson builds a translator that turns the three formats into one canonical tool declaration and routes at the edge. Phase 13 · 17 generalizes the same pattern into an LLM gateway.
The Concept
The common structure
Every provider needs five things:
- Tool list. Per-tool name, description, and input schema.
- Tool choice. Force a specific tool, forbid tools, or let the model decide.
- Call emission. Structured output naming the tool and arguments.
- Call id. Correlate the response to the right call (matters for parallel).
- Result injection. A message or block that ties the result back to the call.
Shape diffs, field by field
| Aspect | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| Declaration envelope | {type: "function", function: {...}} | {name, description, input_schema} | {functionDeclarations: [{...}]} |
| Schema field | parameters | input_schema | parameters |
| Response container | tool_calls[] on assistant message | content[] of type tool_use | parts[] of type functionCall |
| Arguments type | stringified JSON | parsed object | parsed object |
| Id format | call_... (OpenAI generates) | toolu_... (Anthropic) | UUID (Gemini 3+) |
| Result block | role tool, tool_call_id | user with tool_result, tool_use_id | functionResponse with matching id |
| Force-a-tool | tool_choice: {type: "function", function: {name}} | tool_choice: {type: "tool", name} | tool_config: {function_calling_config: {mode: "ANY"}} |
| Forbid tools | tool_choice: "none" | tool_choice: {type: "none"} | mode: "NONE" |
| Strict schema | strict: true | schema-is-schema (always enforced) | responseSchema at request level |
Limits you will actually hit
- OpenAI. 128 tools per request. Schema depth 5. Argument string <= 8192 bytes. Strict mode requires no
$ref, nooneOf/anyOf/allOfwith overlap, every property listed inrequired. - Anthropic. 64 tools per request. Schema depth effectively unbounded but practical limit 10. No strict-mode flag; schema is a contract and the model tends to comply.
- Gemini. 64 functions per request. Schema types are OpenAPI 3.0 subset (slight divergence from JSON Schema 2020-12). Parallel calls unique-id since Gemini 3.
tool_choice behavior
Three modes everyone supports, named differently.
- Auto. Model picks tool or text. Default.
- Required / Any. Model must call at least one tool.
- None. Model must not call tools.
Plus one mode unique to each provider:
- OpenAI. Force a specific tool by name.
- Anthropic. Force a specific tool by name;
disable_parallel_tool_useflag separates single vs multi. - Gemini.
mode: "VALIDATED"routes every response through a schema validator regardless of model intent.
Parallel calls
OpenAI's parallel_tool_calls: true (default) emits multiple calls in one assistant message. You run them all and reply with a batched tool-role message containing one entry per tool_call_id. Anthropic historically did single-call; disable_parallel_tool_use: false (default as of Claude 3.5) enables multi. Gemini 2 allowed parallel calls but did not give stable ids; Gemini 3 adds UUIDs so out-of-order responses correlate cleanly.
Streaming
All three support streamed tool calls. The wire format differs:
- OpenAI. Delta chunks of
tool_calls[i].function.argumentsarrive incrementally. You accumulate untilfinish_reason: "tool_calls". - Anthropic. Block-start / block-delta / block-stop events.
input_json_deltachunks carry partial arguments. - Gemini.
streamFunctionCallArguments(new in Gemini 3) emits chunks with afunctionCallIdso multiple parallel calls can interleave.
Phase 13 · 03 goes deep on parallel + streaming reassembly. This lesson focuses on the declaration and single-call shapes.
Errors and repair
Invalid-argument errors look different too.
- OpenAI (non-strict). Model returns
arguments: "{bad json}", your JSON parse fails, you inject an error message and re-call. - OpenAI (strict). Validation happens during decoding; invalid JSON is impossible but
refusalcan appear. - Anthropic.
inputmay contain unexpected fields; schema is advisory. Validate server-side. - Gemini. OpenAPI 3.0 quirk:
enumon object fields silently ignored; validate yourself.
The translator pattern
A canonical tool declaration in your code looks like this (you pick the shape):
Tool(
name="get_weather",
description="Use when ...",
input_schema={"type": "object", "properties": {...}, "required": [...]},
strict=True,
)Three small functions translate it to the three provider shapes. code/main.py does exactly this, then parses a hand-written response in each provider's shape back into one Call object. No network is involved, because the point is the shapes and not the HTTP.
Production teams wrap this translator in AbstractToolset (Pydantic AI), UniversalToolNode (LangGraph), or BaseTool (LlamaIndex). Phase 13 · 17 ships a gateway that exposes an OpenAI-shaped API in front of any of the three.
Use It
code/main.py defines one canonical Tool dataclass and three translators (to_openai, to_anthropic, to_gemini) that emit each provider's declaration JSON. It also converts a canonical ToolChoice into each provider's tool-choice shape (tool_choice for OpenAI and Anthropic, function_calling_config for Gemini). Then it parses a hand-written response in each provider's format into the same Call object (parse_openai, parse_anthropic, parse_gemini), which shows the semantics match underneath. Run it and compare the three declarations side by side.
What to look at:
- The three declarations differ only in envelope and field names. The Gemini one also uppercases the schema
typevalues and dropsadditionalProperties. - The three responses differ in where the call lives:
tool_callson the message, atool_useentry incontent[], or afunctionCallentry inparts[]. - The final equivalence check confirms all three parse to the same name and the same args.
Ship It
This lesson produces outputs/skill-provider-portability-audit.md. Point it at a function-calling integration written for one provider and it returns a portability audit: which provider limits the code relies on, which fields need renaming, and what breaks on each of the other two.
I'd run main.py first and read the three declarations before the field-by-field table. The canonical Tool carries one input_schema, and the translators barely touch it. OpenAI wraps it in a function envelope and adds strict. Anthropic renames the field. Gemini is the only one that rewrites the schema itself, uppercasing every type and dropping additionalProperties.
The weather tool's units field is worth a closer look. Its type is ["string", "null"] and it is listed in required, which is how the toy satisfies strict mode's every-property-required rule while still letting the value be absent. The Gemini translator only uppercases a type that is a plain string, so that list passes through untouched. A real Gemini integration would need its own handling for nullable fields.
The code stops at shapes. Nothing here sends a request, so it cannot tell you which provider rejects a schema or how it words the error. Treat the limits and error behavior in the body as claims to check against each provider's docs before you depend on them. If you pick one thing to build on, I'd pick the ToolChoice mapping, since it is the piece where a canonical type pays for itself immediately.
Exercises
-
Run
code/main.pyand verify that the three provider declaration JSONs all serialize the same underlyingToolobject. Modify the canonical tool to add an enum parameter and confirm only the Gemini translator needs to handle the OpenAPI quirk. -
Add a
ListToolsResponseparser for each provider that extracts the tool list a model returns after alist_toolsor discovery call. OpenAI does not have one natively; note this asymmetry. -
Implement
tool_choiceconversion: map a canonicalToolChoice(mode="force", tool_name="x")into all three provider shapes. Then mapmode="any"andmode="none". Check the shape-diff table in The Concept. -
Pick one of the three providers and read its function-calling guide end to end. Find one field in its schema spec that the other two do not support. Candidates: OpenAI
strict, Anthropicdisable_parallel_tool_use, Geminifunction_calling_config.allowed_function_names. -
Write a test vector: a tool call whose arguments violate the declared schema. Run it through each provider's validator (the stdlib one in Lesson 01 will do as a proxy) and record which errors fire. Document which provider you would use in production for strictness.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Function calling | "Tool use" | Provider-level API for structured tool-call emission |
| Tool declaration | "Tool spec" | Name + description + JSON Schema input payload |
tool_choice | "Force / forbid" | Auto / required / none / specific-name modes |
| Strict mode | "Schema enforcement" | OpenAI flag that constrains decoding to match schema |
tool_use block | "Anthropic's call shape" | Inline content block with id, name, input |
functionCall part | "Gemini's call shape" | A parts[] entry containing name, args, and id |
| Arguments-as-string | "Stringified JSON" | OpenAI returns args as a JSON string, not an object |
| Parallel tool calls | "Fan-out in one turn" | Multiple tool calls in one assistant message |
| Refusal | "Model declines" | Strict-mode-only refusal block instead of a call |
| OpenAPI 3.0 subset | "Gemini schema quirk" | Gemini uses a JSON-Schema-like dialect with minor differences |
Further Reading
- OpenAI: Function calling guide: canonical reference including strict mode and parallel calls
- Anthropic: Tool use overview:
tool_useandtool_resultblock semantics - Google: Gemini function calling: parallel calls, unique ids, and the OpenAPI subset
- Vertex AI: Function calling reference: Gemini's enterprise surface
- OpenAI: Structured outputs: strict-mode schema enforcement details
Code
- main.py (python, 287 lines)