Portable Tool Calling Across OpenAI, Claude, and Gemini
Map OpenAI, Claude, and Gemini tool fields, validate arguments with JSON Schema, and keep one tools loop across providers through a gateway.
Portable tool calling means your agents define tools once—usually in the OpenAI Chat Completions tools shape—and can invoke those tools through models from OpenAI, Claude, Gemini, or other providers without maintaining three diverging schema dialects in application code. The reliable way to get there is not "hope every native SDK matches," but to standardize on one client contract, validate arguments before side effects, and route inference through an OpenAI-compatible gateway that maps provider differences at the edge.
This article focuses on field mappings, the returned tool-call loop, and an incompatibility matrix. Broader MCP governance, auth, logging platforms, and multi-hop tool routing belong in dedicated posts such as MCP server LLM provider routing and agent tool routing with an MCP gateway.
Why tool schemas become a multi-provider problem
Function calling creates a second API surface: the tool interface. That interface should outlive any single model brand. Native provider formats still differ:
- OpenAI documents tools,
tool_choice, and tool-call messages in its function calling guide. - Anthropic's native Messages API uses tool-use / tool-result content blocks; its OpenAI SDK compatibility path accepts OpenAI-style clients for evaluation but is not Anthropic's long-term production recommendation for most apps.
- Gemini documents function calling in Google AI docs and also offers an OpenAI-compatible endpoint for existing OpenAI clients.
If every agent microservice speaks a different native dialect, your platform accumulates N converters, N log formats, and N failure modes. Portability starts by picking one dialect for product code.
Field mapping cheat sheet
Keep product code on the OpenAI Chat Completions dialect. Expect gateways or adapters to map roughly as follows (names vary by version; verify before relying on edge cases):
| Concept | OpenAI Chat Completions | Claude Messages (native) | Gemini (function calling / OpenAI compat) |
|---|---|---|---|
| Tool catalog | tools[].type=function + function.{name,description,parameters} | tools[] with name, description, input_schema | function_declarations / OpenAI tools on compat endpoint |
| Force or auto | tool_choice: none / auto / required / named tool | tool_choice with Claude-specific shapes | Provider tool_config or OpenAI-style tool_choice on compat |
| Model asks to call | message.tool_calls[] with id, function.name, function.arguments (stringified JSON) | content[] blocks type=tool_use with id, name, input (object) | functionCall parts or OpenAI-shaped tool_calls on compat |
| App returns result | message role=tool + tool_call_id + content string | tool_result block referencing tool_use_id | function response parts / OpenAI role=tool on compat |
| Parallel calls | multiple tool_calls entries | multiple tool_use blocks | multiple function calls when enabled |
| Strict schema | strict / structured outputs (provider-specific) | native structured outputs; OpenAI compat ignores strict | check current Gemini structured-output docs; do not assume OpenAI strict |
Practical rule: normalize to OpenAI on the way into product code, even if a gateway spoke Claude or Gemini natively upstream. Logs and eval harnesses then compare one shape.
JSON Schema that stays portable
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "minLength": 1},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"],
"additionalProperties": false
}
}
}
Keep names stable, descriptions precise, and enums small. Portability dies when each provider experiment renames city to location "because the prompt felt better."
Reference client loop with real validation
json.loads alone is not validation. Use a tool-name allowlist plus JSON Schema (or Pydantic) before any side effect:
from openai import OpenAI
import json
from typing import Any, Callable
# pip install jsonschema
from jsonschema import Draft202012Validator
client = OpenAI(
api_key="GATEWAY_API_KEY",
base_url="https://your-gateway.example.com/v1",
)
TOOL_PARAMS = {
"get_weather": {
"type": "object",
"properties": {
"city": {"type": "string", "minLength": 1},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
"additionalProperties": False,
}
}
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": TOOL_PARAMS["get_weather"],
},
}
]
def get_weather(city: str, unit: str = "celsius") -> dict:
return {"city": city, "unit": unit, "temperature": 22}
HANDLERS: dict[str, Callable[..., dict]] = {
"get_weather": get_weather,
}
def parse_and_validate(name: str, raw_arguments: str) -> dict[str, Any]:
if name not in HANDLERS or name not in TOOL_PARAMS:
raise ValueError(f"tool_not_allowed:{name}")
try:
args = json.loads(raw_arguments or "{}")
except json.JSONDecodeError as exc:
raise ValueError(f"invalid_json:{name}") from exc
if not isinstance(args, dict):
raise ValueError(f"arguments_not_object:{name}")
Draft202012Validator(TOOL_PARAMS[name]).validate(args)
return args
MODEL = "claude-sonnet" # gateway alias; swap to gemini-flash or gpt-* without code rewrites
messages = [{"role": "user", "content": "Weather in Lyon in celsius?"}]
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = response.choices[0].message
messages.append(msg)
if msg.tool_calls:
for call in msg.tool_calls:
try:
args = parse_and_validate(call.function.name, call.function.arguments)
result = HANDLERS[call.function.name](**args)
except Exception as exc:
result = {"error": "tool_validation_failed", "detail": str(exc)}
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
}
)
final = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools,
)
print(final.choices[0].message.content)
Swap only the model alias to try Gemini or OpenAI. Keep the tool executor and JSON Schema unchanged. That is the core portability win.
Response excerpt to assert in tests
A successful first hop should look like this shape (fields abbreviated):
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Lyon\",\"unit\":\"celsius\"}"
}
}
]
}
}
]
}
Your harness should assert: tool name allowlisted, arguments is a JSON object string, schema validates, and the second hop receives role=tool with the same tool_call_id.
Incompatibility matrix (test these, do not assume)
| Failure mode | What you see | Safe handling |
|---|---|---|
| Almost-JSON arguments | trailing commas, single quotes | reject; return tool error content; do not eval |
| Missing required field | schema validation error | return structured tool error; let model retry once |
| Unknown tool name | model invents getWeather | allowlist miss; never execute |
Empty arguments | "" or {} when fields required | validation failure |
| Parallel fan-out | 5+ tool calls in one turn | cap concurrency; especially for mutations |
tool_choice=required ignored | text reply instead of tool call | treat as task failure when side effects are mandatory |
strict=true on Claude compat | silently ignored per Anthropic docs | always validate locally |
| Streaming partial tool args | fragmented argument strings | buffer until final tool-call event |
| Provider refuses tools | safety / policy text only | fail closed; do not invent a tool call in app code |
| Idempotent retry | same create_ticket twice | dedupe keys in the tool layer |
Automate the schema checks. Keep subjective quality scoring out of the portability gate.
What still breaks when providers change
Argument validity
Models can emit wrong enums or missing required fields even when the schema looked "simple." Portable systems validate with the same JSON Schema before side effects. Never execute a payment or write tool on unvalidated arguments because "the model usually complies."
Parallel tool calls
Providers differ on whether multiple tools fire in one turn and how results must be ordered in the next request. Cap fan-out for mutations. Prefer sequential tools when the business action is not commutative.
tool_choice semantics
auto, none, required, and named-tool forcing are not implemented identically everywhere. Include each mode in the test matrix when your product depends on forced tool use.
Strict structured tools versus free-form text
OpenAI-style strict structured outputs, Claude native structured outputs, and Gemini structured outputs are different products. On Anthropic's OpenAI compatibility layer, strict is ignored. Plan for application-level validation regardless of provider marketing language.
Streaming plus tools
Streaming tool calls often arrive as partial argument strings. Buffer until the call is complete before validation. Mid-stream failover after a tool has already mutated state is an application bug, not a model bug.
Side-effect idempotency
Portable tools still need durable idempotency keys for creates and payments. Model retries and client retries will double-submit otherwise.
Gateway responsibilities for portable tools
An AI gateway should:
- accept one ingress tool dialect (typically OpenAI tools)
- map to Claude, Gemini, or OpenAI upstreams without forcing every app to learn native shapes
- publish stable model aliases so tool harnesses do not hardcode provider snapshot IDs
- emit usage logs that include model alias and finish reason for tool turns
- enforce key scopes so a batch key cannot call mutation-heavy agents
Architecture selection (proxy versus gateway) is covered in LLM proxy vs AI gateway. Claude- and Gemini-specific SDK setup lives in use Claude with the OpenAI SDK and use Gemini with the OpenAI SDK.
Eight-case portability matrix
Run these against OpenAI, Claude, and Gemini aliases through the same gateway client:
- No tool needed (pure text)
- Single required tool with valid args
- Missing required arg
- Invalid enum
- Unknown tool name
- Parallel two-tool call (if product allows)
- Forced
tool_choicefor a named tool - Mutation tool idempotent retry
Pass criteria for cases 3–5 and 8 should be validation or executor behavior, not "the model apologized nicely."
Design rules that keep tools portable
- One name, one meaning. Do not overload
searchacross unrelated systems. - Prefer boring JSON. Nested unions and giant free-text blobs reduce cross-model reliability.
- Version tools explicitly (
create_ticket_v2) instead of silent meaning changes. - Separate retrieval tools from mutation tools; apply stricter auth and idempotency to mutations.
- Bound fan-out and recursion. Unlimited tool loops are cost and safety incidents.
- Log tool I/O with redaction. You cannot debug portability without evidence.
OWASP LLM guidance still applies: excessive agency and sensitive data exposure often appear first in tool-enabled systems.
curl smoke test
curl https://your-gateway.example.com/v1/chat/completions \
-H "Authorization: Bearer $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-flash",
"messages": [{"role": "user", "content": "What is the weather in Lyon?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}],
"tool_choice": "auto"
}'
Inspect choices[0].message.tool_calls. Repeat with Claude and OpenAI aliases. Differences in whether a tool is called are product signals; differences in response envelope shape are gateway mapping bugs.
Official sources
- OpenAI function calling guide
- Anthropic OpenAI SDK compatibility
- Gemini OpenAI compatibility
- OWASP Top 10 for LLM Applications
Last checked: 2026-07-15. Re-verify tool_choice, parallel tool calls, and strict schema support per provider before expanding agent side effects.
Common questions
Should every team learn each provider's native tool format?
No for product application code. Keep one ingress schema—typically OpenAI tools—and isolate provider quirks in the gateway or a single adapter library.
Is OpenAI-compatible tool calling enough for production agents?
It is a strong transport and schema strategy, but production still needs allowlists, schema validation, authZ, idempotency, and budgets. Compatibility does not replace those controls.
Can MCP replace portable function calling?
MCP is complementary: it standardizes how tools are exposed to hosts, while model providers still need a way to request invocations. Topology and governance details belong in the MCP routing articles linked above.
Run the matrix before multi-provider failover
Take one production tool schema, run the eight-case matrix against your OpenAI, Claude, and Gemini aliases through /v1/chat/completions, and fix validation gaps before enabling multi-provider failover for that agent. Only then widen traffic.