Tools
Function calling across the gateway's three dialects — declaring a tool, the call-and-result round, and why tool support is proven per model.
Quick
Declare a tool in tools and the model can ask for it to be called instead of answering with text. The description is the only thing that tells the model when to call it.
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Returns the current weather in the named city.",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "The city, for example Kazan." },
"units": { "type": "string", "enum": ["celsius", "fahrenheit"] }
},
"required": ["city"],
"additionalProperties": false
}
}
}
]
}parameters is your own JSON Schema, on all three dialects. Its vocabulary is JSON Schema's and is not narrowed here: nested objects, arrays with items, enum, const, anyOf, format, pattern, numeric bounds and $schema all travel to the model exactly as you wrote them. Two things are judged — that the value is a JSON object, and that it is at most 65536 bytes when encoded. Everything else is between you and the model. A schema the upstream dislikes is refused by the upstream, and you are told the request failed there.
The whole round
A round consists of three turns, and all of its mechanics amount to replaying back what the model said.
- You askAn ordinary request with `tools` added to it.
- The model asks for a callThe reply arrives with `finish_reason: "tool_calls"`, `content` is `null`, and `message.tool_calls` carries the calls: each with its own `id`, a name, and `arguments` as JSON text.
- You answer with the resultSend the conversation again: the same assistant turn with its `tool_calls`, followed by a turn with the `tool` role carrying that call's `tool_call_id` and the result in `content`.
{
"id": "chatcmpl-2b90c1f7ad",
"object": "chat.completion",
"created": 1756900000,
"model": "<model>",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_a1",
"type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"Kazan\",\"units\":\"celsius\"}" }
}
]
},
"finish_reason": "tool_calls"
}
]
}import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.kumorouter.com/v1", api_key=os.environ["KUMO_API_KEY"])
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Returns the current weather in the named city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "The city, for example Kazan."},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
"additionalProperties": False,
},
},
}
]
messages = [{"role": "user", "content": "What is the weather in Kazan right now?"}]
first = client.chat.completions.create(
model="<model>", max_tokens=256, tools=tools, messages=messages
)
call = first.choices[0].message.tool_calls[0]
# This is where you really fetch the weather; the result goes back as text.
result = json.dumps({"temperature": 12, "units": "celsius"})
messages.append(
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": call.id,
"type": "function",
"function": {"name": call.function.name, "arguments": call.function.arguments},
}
],
}
)
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
second = client.chat.completions.create(
model="<model>", max_tokens=256, tools=tools, messages=messages
)
print(second.choices[0].message.content)arguments arrive as text rather than a parsed object: their shape is your own schema, so they cross the boundary as text. Parse them yourself, and validate them before you execute anything.
tool_call_id is required on a tool turn and refused on every other: a supplier matches results to calls by it rather than by position.
Forcing a call
tool_choice states what a request requires of its tool list, and Chat Completions and Responses accept both spellings their protocol defines for it. The bare string is one of three modes: auto leaves the choice to the model, none forbids a call while the declarations stay visible to it, and required requires a call to some declared tool. The object form requires a call to the one tool it names, and that name must be declared by the same request. required with no tool declared is refused, because no answer could satisfy it. Omit the member to leave the choice to the model, which is what auto asks for explicitly.
{ "tool_choice": { "type": "function", "function": { "name": "get_weather" } } }Messages additionally accepts the native object modes below. They are request instructions, not ignored compatibility fields.
{"type": "auto"}Let the model choose whether to call a tool.{"type": "any"}Require a call to some declared tool.{"type": "none"}Forbid a tool call while keeping declarations visible.{"type": "tool", "name": "get_weather"}Require this declared tool.name is required only for tool and rejected for the other three modes. An omitted tool_choice stays absent. Model capability checks still apply.
The same on the other dialects
| What | Chat Completions | Responses | Messages |
|---|---|---|---|
| Declaration | tools[].function with parameters | flat: type, name, parameters | name and input_schema |
| Parameter schema | your own JSON Schema | your own JSON Schema | your own JSON Schema |
| Forcing a call | "auto", "none", "required", or tool_choice.type: "function" with the name inside function | the same three strings, or tool_choice.type: "function" with the name beside it | tool_choice.type: "tool", name beside it |
| The call in the reply | message.tool_calls | a function_call item | a tool_use block |
| The result back | a turn with the tool role and tool_call_id | a function_call_output item with call_id | a tool_result block with tool_use_id |
On the Messages dialect a call's arguments arrive as an object in input rather than as text; in a stream they are assembled from input_json_delta fragments. The Responses declaration carries a strict member that is accepted and not forwarded: this surface proves strict schemas only for structured output.
Parallel calls are promised nowhere. On Responses, parallel_tool_calls is accepted and not forwarded; the other two dialects have no such member at all. Every model family decides it for itself, so write code that survives one call and several equally well.
Tools are proven per model
Tool support is a proven capability of an exact model and channel, not a property of the protocol. The catalog keeps a tools_status for each such pair: proven, unproven or unsupported. A request carrying tools will not be routed to a pair that has not proven them — it is refused before anything upstream is contacted, and "unproven" never means "probably supported". What a given model has proven is on its card in the catalog.