Tool calling lets a model ask your program to run a function and tell it the answer. The model never runs anything itself: it returns a request, your code decides whether to honour it, and the result goes back as another message.
The request and response shapes are OpenAI's, unchanged. Trace adds two
conveniences on top: a Python function can be a tool without writing a schema,
and completion.run_tools(...) handles bookkeeping after your code approves a call.
A tool can be a Python function#
Pass the function. Its name, its type hints, and its docstring become the schema the model reads.
from trace_sdk import Trace
client = Trace()
def get_weather(city: str, unit: str = "celsius") -> str:
"""Current weather for a city.
Args:
city: City name, for example Oslo.
unit: celsius or fahrenheit.
"""
return f"11 degrees and raining in {city}"
completion = client.chat.completions.create(
messages=[{"role": "user", "content": "What is the weather in Oslo?"}],
tools=[get_weather],
)
call = completion.choices[0].message.tool_calls[0]
print(call.function.name) # get_weather
print(call.arguments) # {'city': 'Oslo'}
The description fields are not decoration. They are the only thing the model
reads to decide whether and how to call your function.
The @tool decorator#
Optional. Bare functions already work; the decorator lets you override what the model sees, and attaches the schema to the function so you can read it back.
from trace_sdk import tool
@tool
def get_weather(city: str) -> str:
"""Current weather for a city."""
return f"11 degrees in {city}"
@tool(name="weather", description="Look up today's weather for a city.")
def get_weather_verbose(city: str) -> str:
"""Internal docstring the model never sees."""
return f"11 degrees in {city}"
print(get_weather.tool_schema)
The decorated function is returned unchanged, so calling it directly still works exactly as before.
The loop#
A tool turn is always at least two requests: one where the model asks, one where
it answers with the result in hand. completion.run_tools(...) confirms each
call, runs approved functions, and appends the tool turn to your history.
messages = [{"role": "user", "content": "What is the weather in Oslo?"}]
def approve(call):
return input(f"Run {call.name} with {call.arguments}? [y/N] ").lower() == "y"
completion = client.chat.completions.create(messages=messages, tools=[get_weather])
while completion.choices[0].message.tool_calls:
completion.run_tools(messages, confirm=approve)
completion = client.chat.completions.create(messages=messages, tools=[get_weather])
print(completion.text)
That handles several tools, several calls per turn, and several rounds without
changing. run_tools extends messages in place and returns the messages it
appended, if you want to inspect them.
The explicit form#
For policy checks beyond the confirmation callback, iterate the calls yourself;
call.result(value) builds the message for you:
completion = client.chat.completions.create(messages=messages, tools=[get_weather])
message = completion.choices[0].message
if message.tool_calls:
messages.append(message.to_dict())
for call in message.tool_calls:
if call.name == "get_weather":
messages.append(call.result(get_weather(**call.arguments)))
completion = client.chat.completions.create(messages=messages, tools=[get_weather])
Two details that are easy to get wrong, and that run_tools() handles for you:
- Append
message.to_dict(), not a message you rebuilt. The id that matches a call to its result only lives in there. - Every call you were given must come back with a result. A missing one is usually a 400 from the provider.
What a tool call gives you#
call = completion.choices[0].message.tool_calls[0]
# The mirrored surface — exactly OpenAI's shape.
call.id # "call_abc123"
call.type # "function"
call.function.name # "get_weather"
call.function.arguments # '{"city": "Oslo"}' — a JSON string
# Trace's additions.
call.name # "get_weather"
call.arguments # {"city": "Oslo"} — already parsed
call.result(value) # the tool message answering this call
When a model calls a tool, message.content is None, completion.text is
"", and finish_reason is "tool_calls" — which is why
while completion.choices[0].message.tool_calls: is the right loop condition.
How the schema is built#
| Python | JSON Schema |
|---|---|
city: str | {"type": "string"} |
count: int | {"type": "integer"} |
ratio: float | {"type": "number"} |
enabled: bool | {"type": "boolean"} |
values: list[int] | {"type": "array", "items": {"type": "integer"}} |
unit: Literal["c", "f"] | {"type": "string", "enum": ["c", "f"]} |
tag: Optional[str] | {"type": "string"}, and not required |
limit: int = 10 | {"type": "integer"}, and not required |
| no annotation | {} — untyped rather than an error |
The first paragraph of the docstring becomes the tool's description; an Args:
block becomes the per-parameter descriptions.
Inspect what will be sent:
from trace_sdk import function_to_tool
print(function_to_tool(get_weather))
Raw tool dicts#
When the schema needs to say something Python's types cannot, pass an OpenAI tool dict instead. It goes through unchanged.
tools = [
{
"type": "function",
"function": {
"name": "search",
"description": "Search the course corpus.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "minLength": 3}},
"required": ["query"],
},
},
}
]
completion = client.chat.completions.create(
messages=[{"role": "user", "content": "Find the notes on entropy."}],
tools=tools,
)
A completion built from raw dicts has no functions to run, so run_tools()
raises and tells you to handle the calls yourself. Mixing functions and dicts in
one tools= list is fine; run_tools() runs the functions and reports the rest
back to the model as unknown.
Controlling whether a tool is used#
tool_choice | Effect |
|---|---|
"auto" (default when tools is set) | The model decides. |
"none" | The model must answer in text. |
"required" | The model must call some tool. |
{"type": "function", "function": {"name": "get_weather"}} | The model must call that one. |
Arguments are model output, not user input#
A tool call is text a model generated. It can name a function that does not exist, pass a type you did not ask for, or produce arguments that are not valid JSON at all.
run_tools() can only reach functions you passed in tools=, requires approval
for each call, and a model naming anything else gets
{"error": "There is no tool called …"} back as the result. It also catches
exceptions from your function without sending local exception details upstream.
Malformed JSON leaves call.arguments as {}, with the raw text still on
call.function.arguments. Guard your function against missing keys the way you
would for any untrusted input.
Which models support tools#
Every chat model on the Trace roster does. client.models.list() reports
supports_tools per model, and trace models prints it.
Cost#
Each turn is a separate billed request. A tool loop with one call is two
completions, and the second carries the whole history plus the tool result, so
it is the more expensive of the two. completion.credits_spent on each answer
tells you exactly what that turn cost.
Redaction#
Trace redacts personal information from message text, tool arguments, and tool results before they reach the provider while preserving JSON structure. See Authentication.