Skip to content
Resources

Tool calling

Letting a model call your own Python functions, and returning the results to it.

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#

PythonJSON 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_choiceEffect
"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.