Function Calling on 4 LLMs: 13 Steps, 60 Min [2026]

Function calling lets a language model ask your code to run a tool, then read the result and keep working. Every major vendor ships it, but the request formats differ in exactly the details that break code. On October 27, 2026 the four newest models you are likely to wire up are GPT-6.1 Sol on the Responses API, Claude Sonnet 5.5 on Anthropic’s Messages API, Gemini 4 Argon on Google’s generate_content, and DeepSeek’s deepseek-flash on a chat-completions style endpoint. This function calling tutorial builds one small Python layer, called toolbridge, that declares each tool once and talks to all four.

The timing is not an accident. Google priced Gemini 4 Argon at $2 per million input tokens and $10 per million output tokens as an introductory rate that ends on November 30, 2026, and standard pricing of $4 and $20 starts on December 1. Our breakdown of the Gemini Argon price doubling covers the money side. A function calling setup that logs real cost per run shows you what that change does to your bill before it lands, so the project ships with a cost ledger that flips rates on the date.

Plan for 13 steps and about 60 minutes. Every file of the finished project appears in full below. I ran the 11 offline tests on Python 3.12.3 and they passed in 0.15 seconds. I also pushed fake responses through each adapter using the real vendor SDK classes to confirm the parsing code. I did not call the live model endpoints while writing this, so Step 13 is where you confirm the adapters against your own keys.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

How Function Calling Works and Why Four Models Need Four Adapters

Every function calling exchange follows the same five beats. You send the model a prompt plus a list of tool declarations, each with a name, a description and a JSON Schema for its arguments. The model replies with text, one or more tool calls, or both. Your code validates the arguments, runs the tool and sends back a result that points at the call it answers. The model reads the result and either asks for another tool or writes the final answer. The loop ends when a reply contains no tool calls.

Agents built on that loop are now ordinary engineering. The 2025 Stack Overflow Developer Survey reported that, in its words, “31% of respondents said they currently use AI agents at work.” The same survey found that “69% of developers who had used AI agents at work agreed that they experienced an increase in productivity.” That data is from 2025, so treat it as direction rather than a current share. The practical point stands: the plumbing under those agents is tool calling, and it has to be right.

The plumbing is where the vendors diverge. No single universal function-calling standard is confirmed for these four models as of October 27, 2026, and no official page says they accept the same payload unchanged. The table maps the differences that matter, taken from the SDK shapes the adapters in this tutorial use.

ProviderEndpoint styleTool declarationCall arrives asResult goes back as
OpenAI (GPT-6.1 Sol)responses.createFlat object: type, name, parametersfunction_call output item, JSON string arguments, call_idfunction_call_output input item
Anthropic (Sonnet 5.5)messages.createname, description, input_schematool_use block with an input dicttool_result blocks inside one user message
Google (Gemini 4 Argon)models.generate_contentFunctionDeclaration with parameters_json_schemafunction_call part, args dict, ID may be absentfunction_response part matched by function name
DeepSeek (deepseek-flash)Chat completionsNested: type plus a function objecttool_calls list, JSON string argumentsrole: tool message with tool_call_id

Toolbridge hides those differences behind four small classes. Your tools, your loop and your tests never see a vendor type. That is also the cheapest way to survive the next model rename, which on this release cycle is a matter of weeks.

The Four Models You Are Wiring Up, as of October 27, 2026

Model names move fast this autumn, so confirm what each ID means before you hard-code anything. The table lists only what the vendors’ own pages and recency-checked research support today. Where a price is missing, the vendor page is the source of truth, because the figures I found for those rows conflicted.

ModelAPI IDRelease statusPrice per 1M tokens
GPT-6.1 Solgpt-6.1-solLaunched in late September 2026$2 input, $0.10 cached input, $10 output
Claude Sonnet 5.5claude-sonnet-5-5Launched in late September 2026Check Anthropic’s pricing page
Claude Haiku 5.5claude-haiku-5-5Available nowCheck Anthropic’s pricing page
Gemini 4 Argongemini-4-argonGenerally available October 13, 2026$2 input, $10 output through Nov. 30, then $4 and $20 from Dec. 1
DeepSeek V4.1-Flash-1017deepseek-flashUpdated October 17, 2026Check DeepSeek’s platform page

Three details shape the code. First, Sonnet 5.5 is reportedly listed at $2 input and $10 output, but I could confirm that on a single source only, so the project keeps the figure in an editable JSON file rather than in code you might trust blindly. Second, deepseek-flash is an alias. The October 17 update, covered by Quartz in its write-up of DeepSeek V4.1-Flash-1017, kept the same API identifier, so the model behind the name changed without any change on your side. Third, OpenAI reportedly began rolling out a GPT-6.1 Terra model in the API on October 26 and 27. I found that in one source, so treat it as a reason to keep model IDs in environment variables, which the project does.

If you are still deciding which of these deserve a place in your stack, the side-by-side numbers in our GPT-6.1 Sol vs Sonnet 5.5 vs Gemini 4 Argon comparison are a good starting point. Function calling quality differs per model and per prompt, and only your own test cases settle it. Step 13 gives you those test cases.

Prerequisites and Versions

You need a Linux or macOS shell, about an hour and, for the live step only, API keys for whichever providers you want to test. The offline steps need no keys and no network after installation. Here is what I used:

RequirementVersion usedWhy it matters
Python3.12.3Type hints use X | None with postponed evaluation, so 3.11 or newer should also work
pydantic2.14.1Validates tool arguments and generates JSON Schema
pytest9.1.1Runs the offline suite
openai (Python SDK)Not listedServes both the OpenAI and DeepSeek adapters
anthropic (Python SDK)Not listedMessages API client
google-genaiNot listedGemini client with typed content classes
API keys (optional)4 env varsOPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, DEEPSEEK_API_KEY

Those SDK versions are simply what pip resolved on October 27, 2026. Run pip freeze after your first green test run and pin them, because SDK type definitions change faster than most libraries. If you want help deciding which providers to enable first, the current LLM leaderboard ranks the same models on public benchmarks, though your tool-use accuracy will not necessarily follow those rankings.

Steps 1 and 2: Create the Project and the Neutral Types

Step 1: Scaffold the folder and virtual environment

Create the layout, then install the dependencies into a virtual environment. The tb package holds the library, tests holds the offline suite, and the two scripts at the root are the entry points.

mkdir toolbridge && cd toolbridge
mkdir -p tb/providers tests
touch tb/__init__.py
python3 -m venv .venv
. .venv/bin/activate
pip install pydantic pytest openai anthropic google-genai

Save these package names as requirements.txt too, so a teammate can rebuild the environment with one command:

pydantic>=2
openai
anthropic
google-genai
pytest

Step 2: Define the vendor-neutral message types

Everything else depends on these dataclasses. A Message is one of three roles: user, assistant or tool. An assistant message carries its tool calls, and a tool message carries the results. The raw field stores the provider-native assistant message so an adapter can replay it exactly, which matters for vendors that attach reasoning state to a turn. Usage separates cached input from uncached input because vendors count them differently, as Step 10 shows.

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any


@dataclass
class ToolCall:
    id: str
    name: str
    args: dict[str, Any]


@dataclass
class ToolResult:
    call_id: str
    name: str
    content: str
    is_error: bool = False


@dataclass
class Usage:
    input_tokens: int = 0    # uncached input only
    cached_tokens: int = 0   # input served from the provider cache
    output_tokens: int = 0


@dataclass
class Turn:
    text: str
    calls: list[ToolCall]
    usage: Usage
    raw: Any = None          # provider-native assistant message, replayed verbatim


@dataclass
class Message:
    role: str                # "user" | "assistant" | "tool"
    text: str = ""
    calls: list[ToolCall] = field(default_factory=list)
    results: list[ToolResult] = field(default_factory=list)
    raw: Any = None
    provider: str | None = None

Steps 3 and 4: Declare Tools Once With Validation

Step 3: Build the registry that validates and runs tools

The registry is the safety layer. It turns a pydantic model into the JSON Schema each vendor wants, strips the title keys pydantic adds, and refuses to run anything whose arguments fail validation. Every failure path returns a ToolResult with is_error set instead of raising. That choice is deliberate: an error message the model can read is how it corrects itself on the next turn.

The registry also caps result size at 4,000 characters. A tool that returns a whole database table would otherwise fill the context window and run up the bill. And parse_args handles the case where a model emits malformed JSON, which happens often enough to deserve a code path.

from __future__ import annotations

import json
from typing import Any, Callable

from pydantic import BaseModel, ValidationError

from .types import ToolCall, ToolResult

MAX_RESULT_CHARS = 4000


def parse_args(raw: str | None) -> dict[str, Any]:
    try:
        value = json.loads(raw or "{}")
    except json.JSONDecodeError:
        return {"_unparsed": raw}
    return value if isinstance(value, dict) else {"_unparsed": raw}


def _strip_titles(node: Any) -> Any:
    if isinstance(node, dict):
        return {k: _strip_titles(v) for k, v in node.items() if k != "title"}
    if isinstance(node, list):
        return [_strip_titles(v) for v in node]
    return node


class Tool:
    def __init__(self, name: str, description: str, model: type[BaseModel], fn: Callable[..., Any]):
        self.name, self.description, self.model, self.fn = name, description, model, fn

    def spec(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "parameters": _strip_titles(self.model.model_json_schema()),
        }


class Registry:
    def __init__(self) -> None:
        self._tools: dict[str, Tool] = {}

    def tool(self, model: type[BaseModel], description: str):
        def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
            self._tools[fn.__name__] = Tool(fn.__name__, description, model, fn)
            return fn
        return wrap

    def specs(self) -> list[dict[str, Any]]:
        return [t.spec() for t in self._tools.values()]

    def run(self, call: ToolCall) -> ToolResult:
        tool = self._tools.get(call.name)
        if tool is None:
            known = ", ".join(sorted(self._tools))
            return ToolResult(call.id, call.name, f"Unknown tool '{call.name}'. Available tools: {known}", True)
        if "_unparsed" in call.args:
            return ToolResult(call.id, call.name, f"Arguments were not valid JSON: {str(call.args['_unparsed'])[:200]}", True)
        try:
            args = tool.model(**call.args)
        except ValidationError as exc:
            problems = "; ".join(f"{'.'.join(map(str, e['loc'])) or 'arguments'}: {e['msg']}" for e in exc.errors())
            return ToolResult(call.id, call.name, f"Invalid arguments: {problems}", True)
        try:
            out = tool.fn(args)
        except Exception as exc:  # tool failures go back to the model, not up the stack
            return ToolResult(call.id, call.name, f"{type(exc).__name__}: {exc}", True)
        text = out if isinstance(out, str) else json.dumps(out, default=str)
        if len(text) > MAX_RESULT_CHARS:
            text = text[:MAX_RESULT_CHARS] + f"... [truncated {len(text) - MAX_RESULT_CHARS} chars]"
        return ToolResult(call.id, call.name, text)

Step 4: Write the demo tools

The demo is a small support desk with three tools: look up an order, quote a refund and open a ticket. The data is a hard-coded dictionary so the project runs anywhere. Notice the constraints. Order IDs must match a pattern, the refund reason is a closed set, and extra="forbid" rejects invented arguments. Strict schemas give the model less room to guess and give you cleaner failures.

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

from .registry import Registry

registry = Registry()

ORDERS = {
    "A-1042": {"status": "shipped", "total": 129.90, "currency": "USD", "items": 3},
    "A-2088": {"status": "delivered", "total": 54.00, "currency": "USD", "items": 1},
    "B-3001": {"status": "delivered", "total": 310.50, "currency": "USD", "items": 2},
}
REFUND_PCT = {"damaged": 1.0, "wrong_item": 1.0, "late": 0.2}


class LookupArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    order_id: str = Field(pattern=r"^[A-Z]-\d{4}$", description="Order ID such as A-1042")


class RefundArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    order_id: str = Field(pattern=r"^[A-Z]-\d{4}$", description="Order ID such as A-1042")
    reason: Literal["damaged", "late", "wrong_item"]


class TicketArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    order_id: str = Field(pattern=r"^[A-Z]-\d{4}$")
    summary: str = Field(max_length=200, description="One-sentence issue summary")
    priority: Literal["low", "normal", "high"] = "normal"


@registry.tool(LookupArgs, "Look up an order's status, total and item count by order ID.")
def lookup_order(a: LookupArgs):
    order = ORDERS.get(a.order_id)
    if order is None:
        raise KeyError(f"no order {a.order_id}")
    return {"order_id": a.order_id, **order}


@registry.tool(RefundArgs, "Quote the refund for a delivered order. Fails for orders that are not delivered.")
def refund_quote(a: RefundArgs):
    order = ORDERS.get(a.order_id)
    if order is None:
        raise KeyError(f"no order {a.order_id}")
    if order["status"] != "delivered":
        raise ValueError(f"order {a.order_id} is {order['status']}, refunds need status 'delivered'")
    return {"order_id": a.order_id, "refund": round(order["total"] * REFUND_PCT[a.reason], 2),
            "currency": order["currency"]}


@registry.tool(TicketArgs, "Open a support ticket for an order and return the ticket number.")
def open_ticket(a: TicketArgs):
    return {"ticket": f"T-{abs(hash((a.order_id, a.summary))) % 90000 + 10000}", "priority": a.priority}

Check what the registry exports. This is the exact schema every adapter receives, with no pydantic noise:

python - <<'EOF'
import json
from tb.tools_demo import registry
print(json.dumps(registry.specs()[0]))
EOF
{"name": "lookup_order", "description": "Look up an order's status, total and item count by order ID.", "parameters": {"additionalProperties": false, "properties": {"order_id": {"description": "Order ID such as A-1042", "pattern": "^[A-Z]-\\d{4}$", "type": "string"}}, "required": ["order_id"], "type": "object"}}

Now see the four failure messages the model would receive. These are real outputs from the registry, produced by feeding it a malformed ID, an extra argument, a business-rule failure, an unknown tool and an out-of-set reason:

Invalid arguments: order_id: String should match pattern '^[A-Z]-\d{4}$'
Invalid arguments: verbose: Extra inputs are not permitted
ValueError: order A-1042 is shipped, refunds need status 'delivered'
Unknown tool 'nope'. Available tools: lookup_order, open_ticket, refund_quote
Invalid arguments: reason: Input should be 'damaged', 'late' or 'wrong_item'

Steps 5 to 8: Write the Four Provider Adapters

Each adapter implements one method, complete(system, messages, tools), and returns a Turn. They are stateless: the loop passes the full neutral history every time and the adapter converts it to the vendor format. When the history contains an assistant message produced by the same provider, the adapter replays its stored raw content. When it came from a different provider, the adapter rebuilds the message from the neutral fields. That gives you mid-conversation model handoffs for free.

Step 5: OpenAI Responses adapter for GPT-6.1 Sol

The Responses API takes a flat list of input items. A tool call is a function_call item, and its answer is a function_call_output item with the same call_id. Arguments arrive as a JSON string, so the adapter parses them. The adapter sets store=False so the server keeps no conversation state, which means every output item, including reasoning items, must travel back in the next request. Replaying raw does exactly that. OpenAI’s model page says to set the model in a Responses API request and review its migration guidance if you already use gpt-6-sol.

from __future__ import annotations

import json

from ..registry import parse_args
from ..types import Message, ToolCall, Turn, Usage


class OpenAIResponses:
    name = "openai"

    def __init__(self, model: str = "gpt-6.1-sol", client=None, max_output_tokens: int = 2000):
        if client is None:
            from openai import OpenAI
            client = OpenAI()
        self.client, self.model, self.max_output_tokens = client, model, max_output_tokens

    def _input(self, messages: list[Message]) -> list[dict]:
        items: list[dict] = []
        for m in messages:
            if m.role == "user":
                items.append({"role": "user", "content": m.text})
            elif m.role == "assistant":
                if m.provider == self.name and m.raw is not None:
                    items.extend(m.raw)
                    continue
                if m.text:
                    items.append({"role": "assistant", "content": m.text})
                for c in m.calls:
                    items.append({"type": "function_call", "call_id": c.id, "name": c.name,
                                  "arguments": json.dumps(c.args)})
            else:
                for r in m.results:
                    out = f"ERROR: {r.content}" if r.is_error else r.content
                    items.append({"type": "function_call_output", "call_id": r.call_id, "output": out})
        return items

    def complete(self, system: str, messages: list[Message], tools: list[dict]) -> Turn:
        resp = self.client.responses.create(
            model=self.model,
            instructions=system,
            input=self._input(messages),
            tools=[{"type": "function", "name": t["name"], "description": t["description"],
                    "parameters": t["parameters"]} for t in tools],
            max_output_tokens=self.max_output_tokens,
            store=False,
        )
        text, calls = "", []
        for item in resp.output:
            if item.type == "message":
                text += "".join(p.text for p in item.content if p.type == "output_text")
            elif item.type == "function_call":
                calls.append(ToolCall(item.call_id, item.name, parse_args(item.arguments)))
        u = resp.usage
        cached = getattr(getattr(u, "input_tokens_details", None), "cached_tokens", 0) or 0
        usage = Usage(u.input_tokens - cached, cached, u.output_tokens)
        return Turn(text, calls, usage, raw=[i.model_dump(exclude_none=True) for i in resp.output])

Step 6: Anthropic Messages adapter for Claude Sonnet 5.5

Anthropic wants alternating user and assistant messages. A tool call is a tool_use block inside an assistant message, and every result goes into a single following user message as tool_result blocks. If the model made three parallel calls, one user message carries three results. Splitting them across messages is a classic 400 error, and the adapter prevents it by building one message per tool turn. Note also that Anthropic reports uncached input and cache reads as separate counters, which the adapter maps directly.

from __future__ import annotations

from ..types import Message, ToolCall, Turn, Usage


class AnthropicMessages:
    name = "anthropic"

    def __init__(self, model: str = "claude-sonnet-5-5", client=None, max_tokens: int = 2000):
        if client is None:
            import anthropic
            client = anthropic.Anthropic()
        self.client, self.model, self.max_tokens = client, model, max_tokens

    def _messages(self, messages: list[Message]) -> list[dict]:
        out: list[dict] = []
        for m in messages:
            if m.role == "user":
                out.append({"role": "user", "content": m.text})
            elif m.role == "assistant":
                if m.provider == self.name and m.raw is not None:
                    out.append({"role": "assistant", "content": m.raw})
                    continue
                blocks = [{"type": "text", "text": m.text}] if m.text else []
                blocks += [{"type": "tool_use", "id": c.id, "name": c.name, "input": c.args} for c in m.calls]
                out.append({"role": "assistant", "content": blocks})
            else:
                out.append({"role": "user", "content": [
                    {"type": "tool_result", "tool_use_id": r.call_id, "content": r.content, "is_error": r.is_error}
                    for r in m.results]})
        return out

    def complete(self, system: str, messages: list[Message], tools: list[dict]) -> Turn:
        resp = self.client.messages.create(
            model=self.model,
            max_tokens=self.max_tokens,
            system=system,
            messages=self._messages(messages),
            tools=[{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]}
                   for t in tools],
        )
        text = "".join(b.text for b in resp.content if b.type == "text")
        calls = [ToolCall(b.id, b.name, dict(b.input)) for b in resp.content if b.type == "tool_use"]
        u = resp.usage
        usage = Usage(u.input_tokens, getattr(u, "cache_read_input_tokens", 0) or 0, u.output_tokens)
        return Turn(text, calls, usage, raw=[b.model_dump(exclude_none=True) for b in resp.content])

Step 7: Gemini adapter for Gemini 4 Argon

Gemini uses typed Content and Part objects from the google-genai package. Two details trip people up. The adapter disables automatic function calling, because toolbridge runs the loop itself and you want the logging, validation and cost tracking to happen in your code. And Gemini does not always return a call ID, so the adapter invents one from the message count and part index. Results come back keyed by function name, so the ID is only for your own bookkeeping. Google lists Gemini 4 Argon in the Gemini API, AI Studio and Google Antigravity (see its Antigravity blog post), and the model ID is gemini-4-argon.

from __future__ import annotations

from ..types import Message, ToolCall, Turn, Usage


class GeminiProvider:
    name = "google"

    def __init__(self, model: str = "gemini-4-argon", client=None):
        from google.genai import types
        if client is None:
            from google import genai
            client = genai.Client()
        self.types, self.client, self.model = types, client, model

    def _contents(self, messages: list[Message]) -> list:
        t, out = self.types, []
        for m in messages:
            if m.role == "user":
                out.append(t.Content(role="user", parts=[t.Part(text=m.text)]))
            elif m.role == "assistant":
                if m.provider == self.name and m.raw is not None:
                    out.append(m.raw)
                    continue
                parts = [t.Part(text=m.text)] if m.text else []
                parts += [t.Part.from_function_call(name=c.name, args=c.args) for c in m.calls]
                out.append(t.Content(role="model", parts=parts))
            else:
                out.append(t.Content(role="user", parts=[
                    t.Part.from_function_response(
                        name=r.name, response={"error": r.content} if r.is_error else {"output": r.content})
                    for r in m.results]))
        return out

    def complete(self, system: str, messages: list[Message], tools: list[dict]) -> Turn:
        t = self.types
        config = t.GenerateContentConfig(
            system_instruction=system,
            tools=[t.Tool(function_declarations=[
                t.FunctionDeclaration(name=x["name"], description=x["description"],
                                      parameters_json_schema=x["parameters"]) for x in tools])],
            automatic_function_calling=t.AutomaticFunctionCallingConfig(disable=True),
        )
        resp = self.client.models.generate_content(
            model=self.model, contents=self._contents(messages), config=config)
        content = resp.candidates[0].content
        text, calls = "", []
        for i, part in enumerate(content.parts or []):
            if part.function_call:
                fc = part.function_call
                calls.append(ToolCall(fc.id or f"call_{len(messages)}_{i}", fc.name, dict(fc.args or {})))
            elif part.text and not getattr(part, "thought", False):
                text += part.text
        u = resp.usage_metadata
        cached = getattr(u, "cached_content_token_count", 0) or 0
        usage = Usage((u.prompt_token_count or 0) - cached, cached, u.candidates_token_count or 0)
        return Turn(text, calls, usage, raw=content)

Step 8: DeepSeek adapter and the provider factory

DeepSeek exposes an OpenAI-compatible chat-completions endpoint, so this adapter reuses the OpenAI SDK with a different base URL. Tools nest under a function key, results are role: tool messages, and cache hits show up as prompt_cache_hit_tokens on the usage object. The DeepSeek API platform page is where you confirm current prices. Our GPT-6 Astra vs Gemini 4 Argon vs DeepSeek comparison shows how wide the price gap runs between the cheap and expensive ends of this group.

from __future__ import annotations

import json
import os

from ..registry import parse_args
from ..types import Message, ToolCall, Turn, Usage


class DeepSeekChat:
    name = "deepseek"

    def __init__(self, model: str = "deepseek-flash", client=None):
        if client is None:
            from openai import OpenAI
            client = OpenAI(base_url="https://api.deepseek.com", api_key=os.environ["DEEPSEEK_API_KEY"])
        self.client, self.model = client, model

    def _messages(self, system: str, messages: list[Message]) -> list[dict]:
        out: list[dict] = [{"role": "system", "content": system}]
        for m in messages:
            if m.role == "user":
                out.append({"role": "user", "content": m.text})
            elif m.role == "assistant":
                if m.provider == self.name and m.raw is not None:
                    out.append(m.raw)
                    continue
                msg: dict = {"role": "assistant", "content": m.text or None}
                if m.calls:
                    msg["tool_calls"] = [{"id": c.id, "type": "function",
                                          "function": {"name": c.name, "arguments": json.dumps(c.args)}}
                                         for c in m.calls]
                out.append(msg)
            else:
                for r in m.results:
                    body = f"ERROR: {r.content}" if r.is_error else r.content
                    out.append({"role": "tool", "tool_call_id": r.call_id, "content": body})
        return out

    def complete(self, system: str, messages: list[Message], tools: list[dict]) -> Turn:
        resp = self.client.chat.completions.create(
            model=self.model,
            messages=self._messages(system, messages),
            tools=[{"type": "function", "function": {
                "name": t["name"], "description": t["description"], "parameters": t["parameters"]}} for t in tools],
        )
        msg = resp.choices[0].message
        calls = [ToolCall(c.id, c.function.name, parse_args(c.function.arguments))
                 for c in (msg.tool_calls or [])]
        u = resp.usage
        hit = getattr(u, "prompt_cache_hit_tokens", 0) or 0
        usage = Usage(u.prompt_tokens - hit, hit, u.completion_tokens)
        return Turn(msg.content or "", calls, usage, raw=msg.model_dump(exclude_none=True))

The factory below maps a short name to an adapter and reads the model ID from an environment variable. That one habit absorbs renames, alias changes and new releases like GPT-6.1 Terra without a code change.

import os


def make(name: str):
    if name == "openai":
        from .openai_responses import OpenAIResponses
        return OpenAIResponses(os.getenv("OPENAI_MODEL", "gpt-6.1-sol"))
    if name == "anthropic":
        from .anthropic_messages import AnthropicMessages
        return AnthropicMessages(os.getenv("ANTHROPIC_MODEL", "claude-sonnet-5-5"))
    if name == "google":
        from .gemini import GeminiProvider
        return GeminiProvider(os.getenv("GEMINI_MODEL", "gemini-4-argon"))
    if name == "deepseek":
        from .deepseek import DeepSeekChat
        return DeepSeekChat(os.getenv("DEEPSEEK_MODEL", "deepseek-flash"))
    raise ValueError(f"unknown provider {name}")


KEYS = {"openai": "OPENAI_API_KEY", "anthropic": "ANTHROPIC_API_KEY",
        "google": "GEMINI_API_KEY", "deepseek": "DEEPSEEK_API_KEY"}

Steps 9 and 10: Run the Agent Loop and Price It

Step 9: Write the loop

The loop is short on purpose. It asks the model for a turn, records the usage, and returns if no tools were requested. Otherwise it runs all requested calls in a thread pool, which gives parallel tool calls real concurrency when your tools do network I/O. pool.map preserves order, so results line up with calls. A hard max_steps limit stops a model that keeps calling tools forever, and the result reports stopped="max_steps" so you can alert on it.

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field

from .ledger import Ledger
from .registry import Registry
from .types import Message, ToolCall, ToolResult

SYSTEM = (
    "You are a support assistant. Use the tools to answer. "
    "If a tool returns an error, read it, fix your arguments or explain the limit to the user."
)


@dataclass
class RunResult:
    text: str
    steps: int
    stopped: str                      # "done" | "max_steps"
    calls: list[ToolCall] = field(default_factory=list)
    errors: int = 0
    messages: list[Message] = field(default_factory=list)


def run_agent(provider, registry: Registry, prompt: str, *, system: str = SYSTEM,
              max_steps: int = 6, ledger: Ledger | None = None) -> RunResult:
    messages = [Message("user", prompt)]
    specs = registry.specs()
    made: list[ToolCall] = []
    errors = 0
    for step in range(1, max_steps + 1):
        turn = provider.complete(system, messages, specs)
        if ledger is not None:
            ledger.add(provider.model, turn.usage)
        messages.append(Message("assistant", turn.text, turn.calls, raw=turn.raw, provider=provider.name))
        if not turn.calls:
            return RunResult(turn.text, step, "done", made, errors, messages)
        made.extend(turn.calls)
        with ThreadPoolExecutor(max_workers=len(turn.calls)) as pool:
            results: list[ToolResult] = list(pool.map(registry.run, turn.calls))
        errors += sum(r.is_error for r in results)
        messages.append(Message("tool", results=results))
    return RunResult("", max_steps, "max_steps", made, errors, messages)

Step 10: Add the cost ledger

The ledger converts normalized usage into dollars. Rates live in prices.json, and an entry can carry a from date plus a then block, which is how Gemini 4 Argon flips from $2 and $10 to $4 and $20 on December 1, 2026. Models with a null entry, like DeepSeek here, produce a cost of None and the total refuses to guess. That is better than a silent zero. GPT-6.1 Sol’s cached input rate of $0.10 per million tokens is 5% of its uncached rate, which is why the ledger tracks cached tokens separately. Our GPT-6.1 Sol vs Gemini 4 Argon comparison goes deeper on how those price structures differ.

from __future__ import annotations

import json
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path

from .types import Usage


@dataclass
class Ledger:
    prices: dict
    rows: list[dict] = field(default_factory=list)

    @classmethod
    def load(cls, path: str | Path = "prices.json") -> "Ledger":
        return cls(json.loads(Path(path).read_text()))

    def _rates(self, model: str, today: date) -> dict | None:
        entry = self.prices.get(model)
        if not entry:
            return None
        switch = entry.get("from")
        if switch and today >= date.fromisoformat(switch):
            return entry["then"]
        return entry

    def add(self, model: str, usage: Usage, today: date | None = None) -> float | None:
        rates = self._rates(model, today or date.today())
        cost = None
        if rates and rates.get("input") is not None and rates.get("output") is not None:
            cached_rate = rates.get("cached_input", rates["input"])
            cost = (usage.input_tokens * rates["input"]
                    + usage.cached_tokens * cached_rate
                    + usage.output_tokens * rates["output"]) / 1_000_000
        self.rows.append({"model": model, "usage": usage, "cost": cost})
        return cost

    def total(self, model: str | None = None) -> float | None:
        rows = [r for r in self.rows if model in (None, r["model"])]
        if any(r["cost"] is None for r in rows):
            return None
        return sum(r["cost"] for r in rows)
{
  "gpt-6.1-sol": {"input": 2.00, "cached_input": 0.10, "output": 10.00},
  "claude-sonnet-5-5": {"input": 2.00, "output": 10.00},
  "gemini-4-argon": {
    "input": 2.00, "output": 10.00,
    "from": "2026-12-01",
    "then": {"input": 4.00, "output": 20.00}
  },
  "deepseek-flash": null
}

Fill in the Anthropic and DeepSeek rows from each vendor’s own pricing page before you trust any total. Usage semantics also differ by vendor, and the adapters normalize them. OpenAI reports cached tokens inside the input count, so the adapter subtracts them. Anthropic reports cache reads separately, so it does not. Gemini includes cached content in the prompt count. DeepSeek reports hits as their own field.

Steps 11 and 12: Test the Whole Thing Offline

Step 11: Add a scripted provider

A scripted provider replays prepared turns, so you can test the loop, the registry and the ledger without a network or a key. It records the role sequence of every history it sees, which lets you assert on what the loop would have sent.

from __future__ import annotations

from ..types import Message, Turn, Usage


class Scripted:
    """Replays prepared turns so the loop can be tested without a network."""
    name = "scripted"

    def __init__(self, turns: list[Turn], model: str = "gpt-6.1-sol"):
        self.turns, self.model, self.seen = list(turns), model, []

    def complete(self, system: str, messages: list[Message], tools: list[dict]) -> Turn:
        self.seen.append([m.role for m in messages])
        return self.turns.pop(0)


def turn(text: str = "", calls=None, inp: int = 100, out: int = 20, cached: int = 0) -> Turn:
    return Turn(text, calls or [], Usage(inp, cached, out))

Step 12: Write and run the test suite

The suite covers the paths that fail in production: a clean round trip, parallel calls, bad arguments with recovery, extra arguments, unknown tools, tool exceptions, a runaway loop, schema cleanliness, the Gemini price switch on December 1, an unpriced model and malformed JSON.

from datetime import date

from tb.ledger import Ledger
from tb.loop import run_agent
from tb.providers.scripted import Scripted, turn
from tb.tools_demo import registry
from tb.types import ToolCall, Usage


def call(i, name, **args):
    return ToolCall(f"c{i}", name, args)


def test_single_round_trip():
    p = Scripted([turn(calls=[call(1, "lookup_order", order_id="A-1042")]), turn("It has shipped.")])
    r = run_agent(p, registry, "status of A-1042?")
    assert r.stopped == "done" and r.steps == 2 and r.text == "It has shipped."
    assert [m.role for m in r.messages] == ["user", "assistant", "tool", "assistant"]
    assert "shipped" in r.messages[2].results[0].content


def test_parallel_calls_share_one_tool_message_in_order():
    p = Scripted([turn(calls=[call(1, "lookup_order", order_id="A-1042"),
                              call(2, "refund_quote", order_id="A-2088", reason="damaged")]), turn("ok")])
    r = run_agent(p, registry, "both")
    results = r.messages[2].results
    assert [x.call_id for x in results] == ["c1", "c2"]
    assert '"refund": 54.0' in results[1].content


def test_bad_arguments_come_back_as_errors_and_loop_recovers():
    p = Scripted([turn(calls=[call(1, "lookup_order", order_id="1042")]),
                  turn(calls=[call(2, "lookup_order", order_id="A-1042")]), turn("fixed")])
    r = run_agent(p, registry, "x")
    first = r.messages[2].results[0]
    assert first.is_error and "order_id" in first.content
    assert r.errors == 1 and r.text == "fixed"


def test_extra_arguments_are_rejected():
    p = Scripted([turn(calls=[call(1, "lookup_order", order_id="A-1042", verbose=True)]), turn("done")])
    assert run_agent(p, registry, "x").errors == 1


def test_unknown_tool_lists_available_names():
    p = Scripted([turn(calls=[call(1, "delete_everything")]), turn("sorry")])
    msg = run_agent(p, registry, "x").messages[2].results[0].content
    assert "lookup_order" in msg and "refund_quote" in msg


def test_tool_exception_is_returned_not_raised():
    p = Scripted([turn(calls=[call(1, "refund_quote", order_id="A-1042", reason="late")]), turn("not yet")])
    r = run_agent(p, registry, "x")
    assert r.messages[2].results[0].is_error
    assert "shipped" in r.messages[2].results[0].content


def test_max_steps_stops_a_runaway_loop():
    forever = [turn(calls=[call(i, "lookup_order", order_id="A-1042")]) for i in range(10)]
    r = run_agent(Scripted(forever), registry, "x", max_steps=3)
    assert r.stopped == "max_steps" and r.steps == 3


def test_schema_is_clean_for_every_provider():
    for spec in registry.specs():
        assert "title" not in str(spec["parameters"])
        assert spec["parameters"]["additionalProperties"] is False


def test_ledger_prices_and_gemini_switch_date():
    led = Ledger.load("prices.json")
    u = Usage(1_000_000, 0, 1_000_000)
    assert led.add("gpt-6.1-sol", u) == 12.0
    assert led.add("gemini-4-argon", u, today=date(2026, 11, 30)) == 12.0
    assert led.add("gemini-4-argon", u, today=date(2026, 12, 1)) == 24.0


def test_ledger_flags_unpriced_models():
    led = Ledger.load("prices.json")
    assert led.add("deepseek-flash", Usage(10, 0, 10)) is None
    assert led.total() is None


def test_malformed_json_arguments_become_an_error_result():
    from tb.registry import parse_args
    p = Scripted([turn(calls=[ToolCall("c1", "lookup_order", parse_args('{"order_id": "A-10'))]), turn("retrying")])
    r = run_agent(p, registry, "x")
    assert r.messages[2].results[0].is_error
    assert "not valid JSON" in r.messages[2].results[0].content
python -m pytest -v
collected 11 items

tests/test_loop.py::test_single_round_trip PASSED                        [  9%]
tests/test_loop.py::test_parallel_calls_share_one_tool_message_in_order PASSED [ 18%]
tests/test_loop.py::test_bad_arguments_come_back_as_errors_and_loop_recovers PASSED [ 27%]
tests/test_loop.py::test_extra_arguments_are_rejected PASSED             [ 36%]
tests/test_loop.py::test_unknown_tool_lists_available_names PASSED       [ 45%]
tests/test_loop.py::test_tool_exception_is_returned_not_raised PASSED    [ 54%]
tests/test_loop.py::test_max_steps_stops_a_runaway_loop PASSED           [ 63%]
tests/test_loop.py::test_schema_is_clean_for_every_provider PASSED       [ 72%]
tests/test_loop.py::test_ledger_prices_and_gemini_switch_date PASSED     [ 81%]
tests/test_loop.py::test_ledger_flags_unpriced_models PASSED             [ 90%]
tests/test_loop.py::test_malformed_json_arguments_become_an_error_result PASSED [100%]

============================== 11 passed in 0.15s ==============================

You can also watch a full trace without a network. This short script feeds the loop a scripted two-step conversation with parallel calls and prints what happened, including the cost from the ledger:

from tb.ledger import Ledger
from tb.loop import run_agent
from tb.providers.scripted import Scripted, turn
from tb.tools_demo import registry
from tb.types import ToolCall

script = [
    turn(calls=[ToolCall("c1", "lookup_order", {"order_id": "A-1042"}),
                ToolCall("c2", "refund_quote", {"order_id": "A-2088", "reason": "damaged"})], inp=900, out=60),
    turn("A-1042 has shipped but is not delivered. A-2088 would get a 54.00 USD refund.", inp=1100, out=40, cached=800),
]
ledger = Ledger.load()
r = run_agent(Scripted(script), registry, "status of A-1042, damaged refund for A-2088?", ledger=ledger)
for m in r.messages:
    if m.role == "tool":
        for x in m.results:
            print(f"tool  {x.name:<13} error={x.is_error} -> {x.content}")
    elif m.calls:
        print("model calls:", [(c.name, c.args) for c in m.calls])
    else:
        print(f"{m.role:<5} {m.text}")
print(f"steps={r.steps} stopped={r.stopped} errors={r.errors} cost=${ledger.total():.6f}")
user  status of A-1042, damaged refund for A-2088?
model calls: [('lookup_order', {'order_id': 'A-1042'}), ('refund_quote', {'order_id': 'A-2088', 'reason': 'damaged'})]
tool  lookup_order  error=False -> {"order_id": "A-1042", "status": "shipped", "total": 129.9, "currency": "USD", "items": 3}
tool  refund_quote  error=False -> {"order_id": "A-2088", "refund": 54.0, "currency": "USD"}
assistant A-1042 has shipped but is not delivered. A-2088 would get a 54.00 USD refund.
steps=2 stopped=done errors=0 cost=$0.005080

Check the cost by hand. The first turn used 900 uncached input tokens and 60 output tokens at GPT-6.1 Sol rates, which is $0.0024. The second turn used 1,100 uncached tokens, 800 cached tokens and 40 output tokens, which is $0.00268. The total is $0.00508, matching the ledger. The token counts are scripted inputs for the test, not measurements from a live model.

Step 13: Run the Live Smoke Test Against Your Keys

The smoke script sends one prompt that needs two tools to every provider whose key is set, then checks that both tools were called, the loop finished, and the answer contains the 54.00 refund. It prints one row per provider with steps, calls, tool errors, wall time and cost. Export the keys you have, then run it:

from __future__ import annotations

import argparse
import os
import time

from tb.ledger import Ledger
from tb.loop import run_agent
from tb.providers import KEYS, make
from tb.tools_demo import registry

PROMPT = ("Is order A-1042 delivered yet? Also, what refund would order A-2088 get if it arrived damaged? "
          "Answer both in two short sentences.")


def check(result) -> list[str]:
    problems = []
    names = {c.name for c in result.calls}
    if "lookup_order" not in names:
        problems.append("never called lookup_order")
    if "refund_quote" not in names:
        problems.append("never called refund_quote")
    if result.stopped != "done":
        problems.append(f"stopped={result.stopped}")
    if "54" not in result.text:
        problems.append("answer is missing the 54.00 refund")
    return problems


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--providers", default="openai,anthropic,google,deepseek")
    args = ap.parse_args()
    failed = 0
    print(f"{'provider':<10} {'model':<20} {'steps':>5} {'calls':>5} {'errs':>4} {'secs':>6} {'cost USD':>10}  verdict")
    for name in args.providers.split(","):
        if not os.getenv(KEYS[name]):
            print(f"{name:<10} skipped, {KEYS[name]} not set")
            continue
        provider, ledger = make(name), Ledger.load()
        t0 = time.perf_counter()
        result = run_agent(provider, registry, PROMPT, ledger=ledger)
        secs = time.perf_counter() - t0
        cost = ledger.total()
        problems = check(result)
        failed += bool(problems)
        cost_s = "unpriced" if cost is None else f"{cost:.5f}"
        print(f"{name:<10} {provider.model:<20} {result.steps:>5} {len(result.calls):>5} {result.errors:>4} "
              f"{secs:>6.1f} {cost_s:>10}  {'PASS' if not problems else 'FAIL: ' + '; '.join(problems)}")
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())
export OPENAI_API_KEY=...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export DEEPSEEK_API_KEY=...
python smoke.py --providers openai,anthropic,google,deepseek

With no keys set, the script skips every provider and exits cleanly, which is how it behaves in CI. This is the real output from a keyless run:

provider   model                steps calls errs   secs   cost USD  verdict
openai     skipped, OPENAI_API_KEY not set
anthropic  skipped, ANTHROPIC_API_KEY not set
google     skipped, GEMINI_API_KEY not set
deepseek   skipped, DEEPSEEK_API_KEY not set

With keys, a healthy row shows 2 steps, 2 or more calls, 0 errors and a PASS verdict. DeepSeek shows unpriced in the cost column until you fill its row in prices.json. Two steps is the floor: one turn to request the tools, one to write the answer. A row with 3 or 4 steps and a nonzero error count is not necessarily broken. It usually means the model made a mistake, read your error message and fixed it. That is the loop doing its job, and the error count tells you how forgiving each model needs the loop to be.

Run the script five or ten times per provider before drawing conclusions. A single run tells you whether the adapter works, not how reliable the model is. For a fair comparison, add three or four more prompts to PROMPT that exercise your own tools, and compare error counts across runs.

Common Pitfalls That Break Cross-Provider Tool Calling

  • Trusting the arguments. The model produces arguments, and models make mistakes or get steered by hostile text in a tool result. Validate every call against the schema in code, as the registry does, instead of relying on the schema you sent to the vendor.
  • Leaving a call without a result. Most providers reject a history where a tool call has no matching result. If you skip a call because of a timeout, still return an error result for its ID.
  • Splitting parallel results. Anthropic expects all results for one assistant turn in a single user message. Other vendors accept separate items, so a bug here only shows up on one provider.
  • Raising out of a tool. An uncaught exception ends the run and the model never sees the problem. Catch it, return it as an error result, and let the model decide what to do.
  • Mixing up token accounting. Cached tokens are inside the input count for some vendors and outside it for others. If you add them naively you will double count or undercount, and your cost dashboard will be wrong in a way no test catches.
  • Dropping provider-native state. Reasoning items, thinking blocks and thought signatures must travel back with the turn when the vendor requires them. The raw field exists for this.
  • Hard-coding model IDs. With GPT-6.1 Sol, Sonnet 5.5, Haiku 5.5, Gemini 4 Argon and a DeepSeek alias that changed meaning on October 17, IDs rot within weeks. Read them from the environment.
  • No step cap. A model that keeps calling a failing tool burns tokens at your expense. Keep max_steps low and alert when a run hits it.

The last pitfall has a cost angle. A lower-priced model that needs three retries per task can cost more than a pricier one that succeeds on the first try. The comparison of Sonnet 5.5 against GPT-6 Luna and Gemini 3.8 shows list prices only, so measure retries yourself with the ledger.

Troubleshooting: Nine Errors and Their Fixes

SymptomLikely causeFix
ModuleNotFoundError: No module named 'tb'Running from the wrong folderRun python -m pytest and python smoke.py from the project root, or set PYTHONPATH=.
OpenAI error saying a function call has no outputA call ID was left without a function_call_output, or output items were dropped with store=FalseMake sure the loop returns a result for every call and that the adapter replays raw output items
Anthropic 400 about a tool_use block without a tool_resultResults were split across messages or did not directly follow the assistant turnKeep one user message holding every tool_result for that turn
Gemini returns a call but the answer ignores your resultResult matched by function name, and two same-name calls were reorderedReturn results in call order and keep names unique per turn when possible
A vendor rejects a schema keyword such as pattern or additionalPropertiesThe vendor supports a subset of JSON SchemaKeep the rule in the pydantic model, and filter the keyword out of that adapter’s exported schema
The loop ends with stopped="max_steps"The model repeats a failing callRead the error text the tool returned, make it specific, and sharpen the tool description
Cost column shows unpricedThe model’s row in prices.json is nullCopy the current rates from the vendor’s pricing page into the file
DeepSeek 400 on the second request of a tool turnThe endpoint may require the previous assistant message verbatim, including any reasoning fieldThe adapter already replays raw for same-provider turns, so confirm you are not rebuilding the message from neutral fields
Sonnet 5.5 behaves differently after a migration with thinking disabledA third-party launch roundup reports a new between_tools setting for users who had thinking disabledCheck Anthropic’s release notes for the exact parameter before changing your request

For the last two rows I could not confirm the exact vendor behavior from official pages today, so verify them against current documentation. When a call fails in a way this table does not cover, log the full outgoing request with keys removed. Most cross-provider bugs are visible the moment you read the actual payload.

Advanced Tips: Routing, Caching and Replay Fixtures

  • Route by difficulty. Send simple lookups to a cheap model and hard multi-tool plans to a stronger one. Because every adapter shares one interface, a router is a ten-line function that picks a provider name. See how the pricing tiers compare in our Sonnet 5.5 and DeepSeek V4.1-Flash comparison with Grok 4.7.
  • Record and replay. Save each live Turn to a JSON file, then replay it through the Scripted provider. You get deterministic regression tests built from real model behavior, with no API cost on re-runs.
  • Cache the stable prefix. Tool declarations and the system prompt rarely change, so they are the best candidates for provider-side caching. GPT-6.1 Sol’s cached input costs $0.10 per million tokens against $2 uncached, so a long tool list pays for itself quickly.
  • Use async tools carefully. A single-source report says OpenAI’s GPT-6 guide describes asynchronous tool calling, where the model keeps working while a slow tool runs. If you adopt it, your loop needs to track pending calls across turns, which the current design does not.
  • Force or forbid tools when it helps. Each vendor offers a tool-choice setting. Forcing a specific tool is useful for extraction tasks, and forbidding tools is useful for a final summarizing turn.
  • Set per-tool timeouts. Wrap tool.fn in a timeout and return a timeout error as a normal result. A hung tool should cost the model one turn, not the whole run.
  • Log everything with the call ID. Record the model, call ID, arguments, result and latency. When a customer asks why the assistant did something, that log is your answer.

Security Rules for Tools That Touch Real Systems

The demo tools are harmless. Yours probably are not, so build the checks in before you connect anything that can spend money or change data. Four rules cover most of the risk.

  1. Authorize in code, never in the prompt. The tool function must check that the signed-in user may touch the order, whatever the model says. A system prompt is a suggestion to the model and is not an access control.
  2. Treat tool results as untrusted text. A web page, email or ticket body returned by a tool can contain instructions aimed at the model. Do not give a tool that reads untrusted content the power to also send data out.
  3. Keep destructive tools behind a confirmation step. For refunds, deletes and payments, have the tool return a proposed action that a human or a separate approval service must confirm.
  4. Log denied calls. A model that repeatedly asks for something it may not do is a signal worth alerting on.

Tool design also affects safety. A tool called run_sql that accepts any string is an open door. A tool called lookup_order that accepts one ID-shaped string is hard to abuse. Narrow tools with strict schemas are the best defense you control directly.

The Finished Project and Where to Take It Next

You now have a working function calling layer with four adapters, a validating registry, a parallel loop, a date-aware cost ledger, 11 offline tests and a live smoke script. The final tree looks like this:

toolbridge/
  requirements.txt
  prices.json
  smoke.py
  demo_offline.py
  tb/
    __init__.py
    types.py
    registry.py
    tools_demo.py
    loop.py
    ledger.py
    providers/
      __init__.py
      openai_responses.py
      anthropic_messages.py
      gemini.py
      deepseek.py
      scripted.py
  tests/
    test_loop.py

From here, swap the demo tools for your own, add two or three prompts to the smoke test that mirror real traffic, and run it weekly. The shape of the vendor lineup is changing monthly, so a repeatable check is worth more than any one benchmark. If your use case leans on a cheap, fast model, the numbers in the DeepSeek, Haiku 5.5 and Gemini Flash price comparison will help you pick candidates to add. More guides on models, APIs and evaluation live in the AI and machine learning section.

FAQ: Function Calling Across GPT-6.1 Sol, Claude, Gemini and DeepSeek

What is function calling in an LLM? Function calling is a protocol in which the model returns a structured request to run a named tool with JSON arguments, your code runs the tool, and you send the result back so the model can continue. The model never executes anything itself.

Is function calling the same as tool calling? In practice, yes. Vendors use both terms for the same loop of declarations, calls and results. Some APIs also bundle built-in tools such as search or code execution, but custom functions work the way this tutorial shows.

Can one tool schema work on all four models? The JSON Schema for the arguments can be shared, and toolbridge shares it. The surrounding request and response formats cannot, because they differ per vendor. No official source confirms a single payload that all four accept unchanged.

How much does the smoke test cost? It runs one short prompt per provider with two tool calls, so the cost is small. The ledger prints the exact figure per run. DeepSeek shows as unpriced until you add its rates, and the Gemini 4 Argon rate changes from $2 and $10 to $4 and $20 per million tokens on December 1, 2026.

Should I use LiteLLM or LangChain instead of writing adapters? Those libraries save time when you need many providers fast. Writing the adapters yourself, as here, gives you full control over replay state, token accounting and error text. Choose based on how much behavior you need to own.

How do I stop the model from calling a tool it should not use? Do not expose the tool in that session, and enforce permissions inside the tool function. Prompt instructions alone are not a control. Add a confirmation step for any action that moves money or deletes data.

Why does the model sometimes send invalid arguments? Models predict text and occasionally break a pattern, omit a field or emit malformed JSON. Strict schemas reduce the rate, and returning a clear error message lets the model fix it on the next turn, which the registry and loop handle.

How do I keep model IDs current? Read them from environment variables, check each vendor’s model page when you deploy, and rerun the smoke test after any change. This autumn alone added new IDs and an alias update, so treat model names as configuration.

Nadia Dubois

Nadia Dubois

AI & Innovation Editor

Nadia Dubois is the AI & Innovation Editor at Tech Insider, where she tracks the rapid evolution of artificial intelligence, from foundation models to real-world enterprise deployment. She previously covered AI and startups for La Tribune and contributed to MIT Technology Review's European coverage. Nadia specializes in generative AI, AI regulation, and the intersection of technology and European industrial policy. She holds a dual degree in Computational Linguistics and Journalism from Sciences Po Paris.

View all articles