Building

Tool calling

Tool calling lets the model ask your code to do something — look up a record, hit an API, run a query — and then use the answer. You describe your functions; the model decides when to call them; you run them and hand back the results.

The model never runs anything

It only ever returns a request: this function, these arguments. Your code decides whether to execute it. That boundary is where you put your permission checks.

Describe your tools#

A tool definition
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for a city. Call this whenever the user asks about weather.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. 'Jaipur'"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
},
},
}]
FieldTypeRequiredNotes
typestring YesMust be "function". Anything else returns 400 unsupported_tool_type.
function.namestring YesWhat you will switch on when the call comes back.
function.descriptionstring—The most important field. This is how the model decides whether to call it — say when to use it, not just what it is.
function.parametersobject—JSON Schema. Must be an object schema. Mark the arguments you actually need as required.

What a tool call looks like#

finish_reason: tool_calls
{
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_a1b2c3",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Jaipur\",\"units\":\"celsius\"}"
}
}]
},
"finish_reason": "tool_calls"
}]
}

arguments is a JSON string, not an object

You must JSON.parse / json.loads it. The model generates it, so wrap that parse in a try — a malformed argument string is a thing that happens, and it should become a tool error you feed back, not a crash.

The loop#

Tool calling is a loop, not a call. Keep going until the model returns a message with no tool calls.

import json
 
messages = [{"role": "user", "content": "What's the weather in Jaipur?"}]
 
while True:
response = client.chat.completions.create(
model="upfyn-yuva",
messages=messages,
tools=tools,
)
message = response.choices[0].message
 
if not message.tool_calls:
print(message.content)
break
 
# Append the assistant turn BEFORE the results, or the ids will not line up.
messages.append(message)
 
for call in message.tool_calls:
args = json.loads(call.function.arguments)
result = run_tool(call.function.name, args) # your code
messages.append({
"role": "tool",
"tool_call_id": call.id, # required
"content": json.dumps(result),
})

Always bound the loop

A model that keeps calling tools will keep costing you money. Cap the iterations — eight is usually generous — and return an error to the user if you hit the cap.

Message order matters

  • Append the assistant message with its tool_calls, then one role: "tool" message per call.
  • Every tool message needs a tool_call_id matching the call it answers.
  • Answer all the calls before the next request. A missing result is a broken conversation.
  • Tool call arguments in your history must be valid JSON strings, or you get 400 invalid_tool_arguments.

Controlling when tools are used#

tool_choiceEffect
"auto"The model decides. The default when tools are present.
"none"Never call a tool. Useful for a final summarising turn.
"required"Must call something.
{"type":"function","function":{"name":"x"}}Must call that one.

Parallel calls

Yuva and Rishi can return several tool calls in one turn — check features.parallel_tools on the model. Bala returns one at a time. Set parallel_tool_calls: false if your tools have side effects that must not interleave.

Tools while streaming#

Tool calls arrive in fragments across chunks, exactly as in the OpenAI API: the function name comes first, then arguments accumulates piece by piece.

Accumulate arguments, not the name

Concatenate every arguments fragment for a given index; take the name once. Appending the name on every chunk produces get_weatherget_weatherget_weather and a tool that does not exist — this is the single most common streaming-tools bug.

Tools we host#

Some tools run on our side, so you do not have to implement them. GET /v1/tools lists what is available to your account, and POST /v1/tools/{name} invokes one directly. Web search is the main one; all three models report web_search: true.