Function calling
Function calling lets a model ask your code to do something it cannot do itself, such as look up an order or read a database. You describe your functions. The model picks one and returns its arguments. You run the function, then send the result back so the model can write the final answer. It works on Chat completions, Messages and Responses.
Pick a model that supports tools
Section titled “Pick a model that supports tools”A model supports function calling when tool_call is true in GET /v1/models. The call is public, so you can filter it without a key:
curl -s https://kenari.id/v1/models | jq -r '.data[] | select(.tool_call == true) | .id'kenari does not block a model that lacks the flag, but such a model may ignore your tools. The examples on this page use step-3-7-flash:free, which supports tools and costs nothing to call.
The loop with Chat completions
Section titled “The loop with Chat completions”A tool call is a short conversation: your request, the model’s call, your result, the model’s answer.
- Send the user message and a
toolsarray. - The model returns an assistant message with
tool_callsandfinish_reason: "tool_calls". - Run each function. Append the assistant message, then one
toolmessage per call, each with the matchingtool_call_id. - Send the whole conversation again. The model answers in plain text.
Each tool has a name, a description that tells the model when to use it, and a JSON Schema for parameters. This is the model’s reply to step 2:
{ "choices": [ { "index": 0, "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"Jakarta\"}" } } ] }, "finish_reason": "tool_calls" } ]}arguments is a string that contains JSON, not an object. Parse it yourself. The complete loop with the OpenAI SDK:
import jsonimport os
from openai import OpenAI
client = OpenAI( base_url="https://kenari.id/v1", api_key=os.environ["KENARI_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, for example Jakarta"} }, "required": ["city"], }, }, }]
def get_weather(city: str) -> dict: return {"city": city, "temperature_c": 31, "conditions": "cloudy"}
messages = [{"role": "user", "content": "What is the weather in Jakarta?"}]
for _ in range(5): # stop a runaway loop response = client.chat.completions.create( model="step-3-7-flash:free", messages=messages, tools=tools, ) message = response.choices[0].message if not message.tool_calls: print(message.content) break
messages.append( { "role": "assistant", "content": message.content, "tool_calls": [call.model_dump() for call in message.tool_calls], } ) for call in message.tool_calls: arguments = json.loads(call.function.arguments) result = get_weather(**arguments) messages.append( { "role": "tool", "tool_call_id": call.id, "content": json.dumps(result), } )else: print("Stopped after 5 rounds: the model is still asking for tool calls.")The tool message content is a string. Send JSON as text, or a plain error message if the function failed, so the model can recover.
The loop with Messages
Section titled “The loop with Messages”On /v1/messages a tool is {name, description, input_schema}. The model returns tool_use blocks and stop_reason: "tool_use". You answer with tool_result blocks in the next user message.
Point the Anthropic SDK at https://kenari.id, without /v1. The SDK adds the path itself.
import jsonimport os
import anthropic
client = anthropic.Anthropic( base_url="https://kenari.id", api_key=os.environ["KENARI_API_KEY"],)
tools = [ { "name": "get_weather", "description": "Get the current weather for a city.", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, }]
messages = [{"role": "user", "content": "What is the weather in Jakarta?"}]
for _ in range(5): response = client.messages.create( model="step-3-7-flash:free", max_tokens=2048, tools=tools, messages=messages, ) if response.stop_reason != "tool_use": print("".join(block.text for block in response.content if block.type == "text")) break
messages.append({"role": "assistant", "content": response.content}) results = [] for block in response.content: if block.type == "tool_use": output = {"city": block.input["city"], "temperature_c": 31} results.append( { "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(output), } ) messages.append({"role": "user", "content": results})else: print("Stopped after 5 rounds: the model is still asking for tool calls.")On /v1/responses the model returns function_call items and you reply with function_call_output items that carry the same call_id. Tools are declared flat, as {"type": "function", "name": ..., "parameters": ...}. See Responses.
Control when the model calls a tool
Section titled “Control when the model calls a tool”tool_choice decides whether and which tool the model calls. Without it the model decides.
| Chat completions | Messages | Effect |
|---|---|---|
"auto" | {"type": "auto"} | The model decides. |
"required" | {"type": "any"} | The model must call at least one tool. |
{"type": "function", "function": {"name": "get_weather"}} | {"type": "tool", "name": "get_weather"} | The model must call that tool. |
"none" | {"type": "none"} | The model answers without calling a tool. |
A forced choice without a name is rejected with 400. Unknown shapes are handled per API format. Chat completions treats a shape it does not know, such as allowed_tools, as "auto". Messages and Responses reject an unknown tool_choice type with 400.
Parallel tool calls
Section titled “Parallel tool calls”A model can return several calls in one reply: several entries in tool_calls, or several tool_use blocks. Run all of them, then answer every call. On Chat completions that is one tool message per tool_call_id. On Messages it is every tool_result block in a single user message.
kenari forwards a parallel_tool_calls field only to models served through an OpenAI-style API and drops it for the rest. It is also dropped whenever the request lists a kenari: server tool. Do not rely on it to prevent parallel calls. Write your loop so it handles one call or many.
Streaming tool calls
Section titled “Streaming tool calls”With "stream": true, a call arrives in pieces that you join before parsing. See Streaming for the fields and events of each API format.
Tools that kenari runs for you
Section titled “Tools that kenari runs for you”A tool with the type kenari:<name>, for example {"type": "kenari:web_search"}, runs on kenari. You do not write the loop: the model calls it, kenari runs it and continues the answer. You can mix server tools with your own functions in one tools array. See Server tools.
Limits and pitfalls
Section titled “Limits and pitfalls”- Send the same
toolsarray on every request of the loop, including the one that returns the final answer. - Define every tool that your code runs as a
functiontool. Apart fromkenari:server tools, a tool type that has no function definition is not run by kenari, so do not depend on it. - The model can produce invalid JSON in
arguments, or a value outside your schema. Validate before you run anything with side effects. - A request that declares tools, or a conversation that already holds tool calls and results, is only sent to providers that can carry them, so your tools and results are not silently dropped.