Tool calling Function calling Agents Multi-provider OpenAI compatible

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.

AveMujica API 7 min read

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):

ConceptOpenAI Chat CompletionsClaude Messages (native)Gemini (function calling / OpenAI compat)
Tool catalogtools[].type=function + function.{name,description,parameters}tools[] with name, description, input_schemafunction_declarations / OpenAI tools on compat endpoint
Force or autotool_choice: none / auto / required / named tooltool_choice with Claude-specific shapesProvider tool_config or OpenAI-style tool_choice on compat
Model asks to callmessage.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 resultmessage role=tool + tool_call_id + content stringtool_result block referencing tool_use_idfunction response parts / OpenAI role=tool on compat
Parallel callsmultiple tool_calls entriesmultiple tool_use blocksmultiple function calls when enabled
Strict schemastrict / structured outputs (provider-specific)native structured outputs; OpenAI compat ignores strictcheck 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 modeWhat you seeSafe handling
Almost-JSON argumentstrailing commas, single quotesreject; return tool error content; do not eval
Missing required fieldschema validation errorreturn structured tool error; let model retry once
Unknown tool namemodel invents getWeatherallowlist miss; never execute
Empty arguments"" or {} when fields requiredvalidation failure
Parallel fan-out5+ tool calls in one turncap concurrency; especially for mutations
tool_choice=required ignoredtext reply instead of tool calltreat as task failure when side effects are mandatory
strict=true on Claude compatsilently ignored per Anthropic docsalways validate locally
Streaming partial tool argsfragmented argument stringsbuffer until final tool-call event
Provider refuses toolssafety / policy text onlyfail closed; do not invent a tool call in app code
Idempotent retrysame create_ticket twicededupe 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:

  1. No tool needed (pure text)
  2. Single required tool with valid args
  3. Missing required arg
  4. Invalid enum
  5. Unknown tool name
  6. Parallel two-tool call (if product allows)
  7. Forced tool_choice for a named tool
  8. 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 search across 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

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.