Tool calling (function calling)
Declare tools with the OpenAI schema, run the full tool-call loop in Python, steer the model with tool_choice, and accumulate tool_calls while streaming.
Updated: September 7, 2026
Tool calling means telling the model which functions it has available, letting it decide which one to invoke and with what arguments, running that function yourself, and feeding the result back so the model can compose its final answer. uttapen passes the OpenAI schema straight through, so whatever you learned against gpt-5 works identically with anthropic/claude-sonnet-4.5 or google/gemini-2.5-flash.
Which models support it
Look for the tools badge on the models page. In GET /v1/models the equivalent is "tools" appearing in the supported_parameters array. Most mainstream models support it; a model that does not will either return a 400 from the provider or quietly ignore the tools. For production work, pick a model that also lists parallel_tool_calls.
Declaring a tool
tools = [
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Looks up the status of an order in the shop database.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Order number, e.g. ORD-1042"},
},
"required": ["order_id"],
"additionalProperties": False,
},
},
}
]
Take description seriously — it is the only thing the model uses to decide when to reach for the tool. Writing it in Persian is fine if that suits your product, but keep function names and field names in latin snake_case.
The full loop in Python
import json
from openai import OpenAI
client = OpenAI(base_url="https://api.uttapen.ir/v1", api_key="sk-up-...")
MODEL = "openai/gpt-5-mini"
def get_order_status(order_id: str) -> dict:
# Query your own database here
return {"order_id": order_id, "status": "shipped", "eta_days": 2}
available = {"get_order_status": get_order_status}
messages = [{"role": "user", "content": "Where is my order ORD-1042?"}]
while True:
resp = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
msg = resp.choices[0].message
messages.append(msg) # the assistant message carrying tool_calls must stay in the history
if not msg.tool_calls:
print(msg.content)
break
for call in msg.tool_calls:
fn = available[call.function.name]
args = json.loads(call.function.arguments)
result = fn(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
Things to watch in that loop:
- The assistant message that contains
tool_callshas to go back into the history verbatim, otherwise the provider rejects thetoolmessage that follows it. - Each result's
tool_call_idmust match theidof its call. With several parallel calls the order does not matter, but the ids do. argumentsis a JSON string — alwaysjson.loadsit and be ready for a parse error. Models occasionally emit malformed JSON; when that happens, return atoolmessage containing the error text so the model can try again.- Cap the number of iterations (8 is a reasonable ceiling) so a model stuck in a loop cannot drain your wallet. Every iteration is a full request carrying the whole history, and every request costs money.
tool_choice
| Value | Behaviour |
|---|---|
"auto" (the default whenever tools is present) | the model decides |
"none" | text only; the tools are ignored |
"required" | at least one tool must be called |
{"type": "function", "function": {"name": "get_order_status"}} | call this specific tool |
"required", and forcing one particular function, are handy when you want structured output but do not trust the model's response_format support: in practice the function arguments are valid JSON matching your schema. See structured output for the alternative.
Streaming tool_calls
In a stream, delta.tool_calls arrives in fragments: first the id and name, then arguments as a series of partial strings you have to concatenate yourself. The final chunk carries finish_reason: "tool_calls".
stream = client.chat.completions.create(model=MODEL, messages=messages, tools=tools, stream=True)
calls = {}
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
for tc in delta.tool_calls or []:
slot = calls.setdefault(tc.index, {"id": None, "name": "", "arguments": ""})
if tc.id:
slot["id"] = tc.id
if tc.function and tc.function.name:
slot["name"] = tc.function.name
if tc.function and tc.function.arguments:
slot["arguments"] += tc.function.arguments
if delta.content:
print(delta.content, end="", flush=True)
Once the stream ends, execute calls exactly as in the non-streaming case and send the tool messages back. If you would rather not accumulate by hand, the Node and Go SDKs ship an accumulator that does it for you (Go SDK).
Cost
Every turn of the loop is a separate request, and your tool definitions count as part of the prompt each time. With ten verbosely described tools you are adding several hundred input tokens per turn. Ways to bring that down: filter the tool list by conversation context, write short and precise descriptions, and use a cheap model for the decision turns while reserving the strong model for the final answer. Sum the X-Uttapen-Cost-Toman header across turns to see what a whole conversation cost.