Skip to content
RU

Tool calling

Tool calling (function calling) gives the model access to your data and actions: an orders database, an internal API, a calculator. Instead of answering right away, the model asks your code to run a function, receives the result, and continues from there. This is how assistants, agents, and bots that can “see” your systems are built.

Tools are supported in all three chat protocols: /v1/chat/completions, /v1/messages, and /v1/responses. The first one is the primary; the other two are covered briefly below.

  1. You send a regular request plus a tools array: function descriptions with a name, a description, and a JSON Schema of arguments.
  2. The model decides it needs a tool and answers with a call instead of text: the function name and a JSON of arguments. The finish_reason of such a response is "tool_calls".
  3. Your code executes the function: queries a database, calls an external API, computes — anything.
  4. You send the result back in the same thread: the entire prior history plus a message with the tool role.
  5. The model reads the result and replies with the final text for the user.

The split of responsibility is strict: the model never executes code itself. It only picks a tool and fills in the arguments — execution always happens on your side, and only you decide what to actually run and what to reject. The model has no access to your systems: it sees only the tools you described in the request and only the results you return to the conversation yourself.

A tool is described in the tools field:

{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather in a given city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"]
}
}
}

The model reads name and description to understand when the tool is needed; parameters is the JSON Schema of arguments the model builds the call against.

The tool_choice field controls how the model uses tools:

Value Behavior
auto Default: the model decides whether to call a tool or answer in text
none Tools are not used
required The model must call at least one tool
{"type": "function", "function": {"name": "get_weather"}} Call this specific function

When the model decides to call a tool, the response comes without text — with a list of tool_calls and finish_reason: "tool_calls":

{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_9c2f1e",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"Kazan\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}

A common pitfall: arguments is a JSON string, not an object. Parse it before working with the arguments: json.loads(...) in Python, JSON.parse(...) in JavaScript.

The second round is the same endpoint, but messages now carries:

  1. the original user message;
  2. the assistant response the API returned, in full — with its tool_calls (do not rebuild it by hand; pass the object as is);
  3. the result — a message with the tool role:
{
"role": "tool",
"tool_call_id": "call_9c2f1e",
"content": "{\"temp\": 18, \"condition\": \"overcast\"}"
}

tool_call_id must match the id from tool_calls. content is a string; for structured data, returning JSON is convenient.

import json
from openai import OpenAI
client = OpenAI(
base_url="https://api.mixen.ai/v1",
api_key="mxn-...",
)
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather in a given city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"]
}
}
}]
def get_weather(city: str) -> str:
# a real call to your backend or an external API would live here
return json.dumps({"city": city, "temp": 18, "condition": "overcast"})
messages = [{"role": "user", "content": "What's the weather in Kazan? Walk or take an umbrella?"}]
while True:
resp = client.chat.completions.create(
model="gpt-5.6-luna",
messages=messages,
tools=tools,
)
msg = resp.choices[0].message
messages.append(msg) # the assistant response in full, tool_calls included
if not msg.tool_calls: # finish_reason "stop" — final text
print(msg.content)
break
for call in msg.tool_calls:
args = json.loads(call.function.arguments) # arguments is a JSON string
result = get_weather(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})

The response to this request is the tool_calls JSON shown above.

The loop does not have to end after one round: if, given the result, the model decides to call another tool — or another one — the loop continues on its own until a response arrives with text. That is how agentic scenarios work.

A single response may carry several tool_calls at once — say, weather in three cities or a search across several sources. The client executes all of them (in parallel, if you like) and returns one role: "tool" message per call — in the same order the calls arrived, each with its own tool_call_id.

stream: true works with tools, but behaves differently from plain text. Text still arrives in deltas, while tool_calls do not: the assembled list of calls is emitted whole, in a single chunk near the end of the stream, and finish_reason: "tool_calls" lands in the last chunk. You will not need code to stitch arguments out of deltas — but there is no “streaming of calls” either.

If you do not need to show intermediate text to the user live, calling without stream is simpler — less chunk-assembly code, same behavior.

The Anthropic protocol has the same ideas, but the fields are named differently and the arguments arrive as an object, not a string:

  • Declaration: tools: [{"name": "...", "description": "...", "input_schema": <JSON Schema>}] — no function wrapper.
  • Response: a {"type": "tool_use", "id": "...", "name": "...", "input": {...}} block inside the content array; stop_reason: "tool_use". input is already an object — no parsing needed.
  • Second round: a {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "...", "content": "<result as a string>"}]} message — one tool_result per tool_use.
from anthropic import Anthropic
client = Anthropic(base_url="https://api.mixen.ai", api_key="mxn-...")
resp = client.messages.create(
model="anthropic/claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": "What's the weather in Kazan?"}],
tools=[{
"name": "get_weather",
"description": "Current weather in a given city",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}],
)
block = next(b for b in resp.content if b.type == "tool_use")
result = get_weather(**block.input) # input is an object — no json.loads
final = client.messages.create(
model="anthropic/claude-sonnet-5",
max_tokens=1024,
messages=[
{"role": "user", "content": "What's the weather in Kazan?"},
{"role": "assistant", "content": [block]}, # the tool_use block as is
{"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
}]},
],
)
print(final.content[0].text)

The base URL for the Anthropic SDK has no /v1 suffix: the SDK adds the path itself. See Claude Code.

In the Responses API the tool declaration is flat — no nested function:

  • Declaration: tools: [{"type": "function", "name": "...", "description": "...", "parameters": <JSON Schema>}]
  • Response: an output item of the form {"type": "function_call", "call_id": "...", "name": "...", "arguments": "<JSON string>"} — here arguments is a string again.
  • Second round: input carries the whole history — messages with "type": "message", the function_call item itself as is, and next to it {"type": "function_call_output", "call_id": "...", "output": "<result as a string>"}.
import json
from openai import OpenAI
client = OpenAI(base_url="https://api.mixen.ai/v1", api_key="mxn-...")
resp = client.responses.create(
model="gpt-5.6-luna",
input="What's the weather in Kazan?",
tools=[{
"type": "function",
"name": "get_weather",
"description": "Current weather in a given city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}],
)
call = next(o for o in resp.output if o.type == "function_call")
result = get_weather(**json.loads(call.arguments)) # arguments is a JSON string
final = client.responses.create(
model="gpt-5.6-luna",
input=[
{"type": "message", "role": "user", "content": "What's the weather in Kazan?"},
call, # the function_call item as is
{"type": "function_call_output", "call_id": call.call_id, "output": result},
],
)
print(final.output_text)
  • Billing is the usual one — input and output tokens at the model’s rates. Tool declarations count as input and are billed as input tokens: in agentic loops with a large tool list this adds up; context caching helps.
  • A tool role with no call is an error. If you send a role: "tool" message that is not preceded by an assistant response with tool_calls, the upstream returns 400. Return results only in response to a real call, and in the same thread.
  • Support depends on the model. A model that cannot use tools returns 400. Nearly all chat models in the catalog support them; check a specific model’s page for nuances.
  • Further reading: Chat and streaming — the main endpoint’s parameters, Errors — codes, limits, retries.