Skip to content

DocsFeatures

Tool calling

Use the OpenAI tools schema with every tool-capable model on INFRO. Covers the full loop, tool_choice, parallel calls, and streaming deltas.


INFRO is in private early access. Running these examples requires an early-access account, an active trial or subscription entitlement, and your own provider connection. Self-serve purchasing is not generally open. See Early access.

Every tool-capable model on INFRO uses the same OpenAI tools and tool_choice schema. Define tools once. Most provider kinds speak the OpenAI protocol, so INFRO forwards the schema to them as is; for Anthropic connections INFRO translates tools, tool_choice, tool calls and tool results to and from Anthropic's tool_use blocks. The shapes on this page are the same whichever of your connections serves the request.

Tool calling is a loop: send tool definitions with a chat completion, the model responds with one or more tool_calls instead of text, you execute them and return the results, and the model produces its final answer. This page walks the full loop, then covers tool_choice, parallel calls, and streaming.

Check model support

INFRO does not record tool support per model yet — the capabilities array in GET /v1/models is empty — so check the model vendor's documentation. Most current-generation text models support tools, but capability sets differ between a vendor's flagship and its small models, so test at integration time rather than assuming. If a provider rejects the tools parameter, INFRO returns a 400 carrying the provider's own message.

Define a tool

Tools go in the top-level tools array. Each entry describes one function the model may call; arguments are specified as JSON Schema.

typestringrequired
Always "function".
function.namestringrequired
The name the model uses to call the tool. Letters, numbers, underscores, and dashes; 64 characters max.
function.descriptionstring
What the tool does and when to use it. The model leans on this heavily when deciding whether to call — write it for the model, not for humans.
function.parametersobject
JSON Schema for the arguments. Keep it flat where you can, and use enum and per-property description fields — they measurably improve argument quality.
get_weather tool definition
{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "Get the current weather for a city. Use when the user asks about weather conditions.",
    "parameters": {
      "type": "object",
      "properties": {
        "city": {
          "type": "string",
          "description": "City name, e.g. \"Lisbon\""
        },
        "unit": {
          "type": "string",
          "enum": ["celsius", "fahrenheit"],
          "description": "Temperature unit. Defaults to celsius."
        }
      },
      "required": ["city"]
    }
  }
}

Tool definitions are part of the prompt prefix, so keep the tools array byte-stable across requests — system prompt first, then tools, then history — and provider-side prompt caching can keep hitting.

The tool-calling loop

When the model decides to call a tool, the response has finish_reason "tool_calls" and the assistant message carries a tool_calls array instead of (or alongside) text content. Each entry:

idstring
Unique id for this call. Echo it back as tool_call_id on the result message.
typestring
Always "function".
function.namestring
Which tool to run.
function.argumentsstring
JSON-encoded arguments — a string, not an object. It is model-generated: parse defensively and validate before executing anything with side effects.

Sending results back has three rules: append the assistant message exactly as received (it carries the tool_calls your results answer), append one "tool" role message per call with tool_call_id set to the call's id and content set to the result as a string, then request the next completion with the same tools array. The complete loop:

import os
import json
from openai import OpenAI

client = OpenAI(
    base_url="https://api.infro.io/v1",
    api_key=os.environ["INFRO_API_KEY"],
)

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get the current weather for a city.",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "City name"},
                    "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
                },
                "required": ["city"],
            },
        },
    }
]

def get_weather(city: str, unit: str = "celsius") -> str:
    # Replace with a real lookup
    return json.dumps({"city": city, "temp": 21, "unit": unit, "conditions": "clear"})

messages = [{"role": "user", "content": "What's the weather in Lisbon right now?"}]

response = client.chat.completions.create(
    model="anthropic/claude-sonnet-5",
    messages=messages,
    tools=tools,
)
message = response.choices[0].message

while message.tool_calls:
    # 1. Append the assistant turn that requested the calls
    messages.append(message)

    # 2. Execute every call, one tool message per tool_call_id
    for tool_call in message.tool_calls:
        if tool_call.function.name == "get_weather":
            args = json.loads(tool_call.function.arguments)
            result = get_weather(**args)
        messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,
            "content": result,
        })

    # 3. Ask for the next turn with the same tools
    response = client.chat.completions.create(
        model="anthropic/claude-sonnet-5",
        messages=messages,
        tools=tools,
    )
    message = response.choices[0].message

print(message.content)

The loop runs until the model answers in plain text (finish_reason "stop"); multi-step tasks commonly take two or three iterations — the model calls a tool, reads the result, and calls another. If you use fallbacks, pick fallback models that also support tools — one that can't call tools answers in plain text instead — and read the response model field to see which model actually served each turn.

tool_choice

tool_choice controls whether the model may, must, or must not call tools. OpenAI-protocol providers receive it unchanged; on Anthropic connections INFRO maps each value to Anthropic's equivalent (auto, none, any, a named tool). A tool_choice sent without tools is dropped on Anthropic connections.

ValueBehavior
"auto"The model decides whether to call a tool or answer directly. Default whenever tools is present.
"none"Tools are never called. Definitions still count toward prompt tokens.
"required"The model must call at least one tool before answering.
{"type": "function", "function": {"name": "get_weather"}}Forces a call to the named tool.

Forcing a tool guarantees a call, not good arguments — a model pushed into a tool it wouldn't have chosen will invent parameters it doesn't have. Validate arguments before executing, especially with "required" or a forced function.

Parallel tool calls

Where the underlying model supports it, one assistant turn can carry several entries in tool_calls — independent lookups batched into a single round trip. Execute them all (concurrently if you like) and append one "tool" message per call. Order doesn't matter; results are matched by tool_call_id, not position. The loop above already handles this — its for loop over message.tool_calls returns every result before the next request.

Return a "tool" message for every tool_call_id in the turn before requesting the next completion. A missing result makes the conversation invalid, and most providers reject it with a 400 — see error handling. If a tool fails, return the error text as the result rather than omitting it; models recover well from described failures.

Streaming tool calls

With stream: true, tool calls arrive incrementally in delta.tool_calls. Fragments are keyed by index within the turn — the first fragment of each call carries the id, type, and function.name; subsequent fragments append pieces of the function.arguments string. Concatenate arguments per index until the chunk with finish_reason "tool_calls", then parse.

SSE chunks for one streamed tool call
data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9f2b","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"Lis"}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"bon\"}"}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}

data: [DONE]

Parallel calls interleave in the same stream, distinguished by index. The SSE format, usage accounting via stream_options, and mid-stream failure behavior are covered in streaming.

Which models to use

Tool-use quality varies more across models than plain-text quality does, and it shifts between versions — benchmark on your own tools before committing. Frontier models such as anthropic/claude-sonnet-5, anthropic/claude-opus-5 and openai/gpt-5.6-sol are the usual starting point for long multi-step chains; open-weight models such as moonshot/kimi-k2.6 are worth including in the comparison.

For high-volume, single-call workloads — one lookup, one answer — a smaller model such as openai/gpt-5.6-luna or google/gemini-3.7-flash is often enough; compare the vendors' published rates and the estimated cost INFRO records per request. And if what you need is validated JSON output rather than executed actions, use structured outputs instead: tool schemas describe actions, response_format describes shapes.

↑ ↓ navigate↵ open/ anywhere in docs