Chapter 6 of 21 · intermediate
Function calling
What this session covers
Ask a model what 26.2 miles is in kilometers and it will answer with confidence and, some noticeable fraction of the time, with the wrong number. That's not a flaw you prompt away — weights don't do arithmetic, they approximate it. This hour is about the mechanism that fixes it: you declare a function the model may call, the model decides when to call it and with what arguments, your Python runs it, and the model writes the answer around the exact result.
Read that sequence again, because the division of labor is the entire lesson. The model never runs anything. It emits a request — "call convert with these arguments" — and stops. Your code executes the function, on your machine, under your control, and hands the result back for a second model call to narrate. Everything the agent chapters do later is this same loop with more tools and more turns. This week is one tool and one round trip, done properly.
OpenAI
The tool is declared as JSON Schema: a name, a description, and the exact
parameters with their types. Treat the description as a prompt — it's what the
model reads when deciding whether and how to call. The example gives the model
one function, convert, and asks the marathon question.
"""Week 5 - Function calling (OpenAI).
The model can't do exact unit math, but your Python can. Declare one
function as a tool, let the model decide to call it, run the real code,
hand the result back, and get a grounded answer. One tool, one round trip.
"""
import json
import os
import sys
from dotenv import find_dotenv, load_dotenv
from openai import OpenAI
load_dotenv(find_dotenv())
if not os.environ.get("OPENAI_API_KEY"):
sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")
client = OpenAI()
# region: the-function
# The actual work is plain Python — exact, deterministic, testable.
# The model never does this math; it only asks for it.
FACTORS_TO_METERS = {"km": 1000.0, "miles": 1609.344, "meters": 1.0, "feet": 0.3048}
def convert(value: float, from_unit: str, to_unit: str) -> float:
meters = value * FACTORS_TO_METERS[from_unit]
return round(meters / FACTORS_TO_METERS[to_unit], 3)
# endregion
# region: tool-schema
# The tool declaration is a JSON Schema: name, what it does, and the
# exact parameters. The description is a prompt — the model reads it to
# decide WHEN to call and WHAT to pass.
TOOLS = [{
"type": "function",
"name": "convert",
"description": "Convert a length between units exactly.",
"parameters": {
"type": "object",
"properties": {
"value": {"type": "number"},
"from_unit": {"type": "string", "enum": ["km", "miles", "meters", "feet"]},
"to_unit": {"type": "string", "enum": ["km", "miles", "meters", "feet"]},
},
"required": ["value", "from_unit", "to_unit"],
},
}]
# endregion
QUESTION = "A marathon is 26.2 miles. How many kilometers is that, exactly?"
# region: round-trip
# Call 1: the model reads the question and, instead of answering, emits a
# function_call item — the function name plus arguments as a JSON string.
response = client.responses.create(
model="gpt-5.4-nano",
input=QUESTION,
tools=TOOLS,
)
call = next(item for item in response.output if item.type == "function_call")
args = json.loads(call.arguments)
print(f"model wants: {call.name}({args})")
# YOUR code runs the function. The model only chose it.
result = convert(**args)
# Call 2: hand the result back, linked by call_id. previous_response_id
# carries the whole conversation state so we only send the new part.
final = client.responses.create(
model="gpt-5.4-nano",
previous_response_id=response.id,
input=[{
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps({"result": result}),
}],
tools=TOOLS,
)
# endregion
print(final.output_text)
Two things to internalize. First, the model's reply to call one isn't text —
it's a function_call item carrying the function name and the arguments as a
JSON string that you parse and validate; the arguments are model output, so
enum-constrain what you can and treat the rest like user input. Second, the
result goes back linked by call_id, and previous_response_id carries the
conversation state so the second request only ships the new part. Forget the
tools parameter on that second call and you'll get a confused model that no
longer remembers what a convert is.
Gemini
Same function, same round trip, different spelling. Gemini declares tools with
typed Schema objects instead of a raw JSON Schema dict, and the call comes
back on response.function_calls with the arguments already parsed into a
dict — no json.loads step.
"""Week 5 - Function calling (Gemini).
Same lesson, same function, so the two SDKs read side by side. Gemini
takes typed FunctionDeclarations on the config; the call comes back as a
structured part with the args already parsed into a dict.
"""
import os
import sys
from dotenv import find_dotenv, load_dotenv
from google import genai
from google.genai import types
load_dotenv(find_dotenv())
if not os.environ.get("GEMINI_API_KEY"):
sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# region: the-function
FACTORS_TO_METERS = {"km": 1000.0, "miles": 1609.344, "meters": 1.0, "feet": 0.3048}
def convert(value: float, from_unit: str, to_unit: str) -> float:
meters = value * FACTORS_TO_METERS[from_unit]
return round(meters / FACTORS_TO_METERS[to_unit], 3)
# endregion
# region: tool-schema
# Gemini declares tools with typed Schema objects instead of raw JSON
# Schema dicts — same information, different spelling.
CONVERT_DECL = types.FunctionDeclaration(
name="convert",
description="Convert a length between units exactly.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"value": types.Schema(type=types.Type.NUMBER),
"from_unit": types.Schema(type=types.Type.STRING, enum=["km", "miles", "meters", "feet"]),
"to_unit": types.Schema(type=types.Type.STRING, enum=["km", "miles", "meters", "feet"]),
},
required=["value", "from_unit", "to_unit"],
),
)
CONFIG = types.GenerateContentConfig(
tools=[types.Tool(function_declarations=[CONVERT_DECL])],
)
# endregion
QUESTION = "A marathon is 26.2 miles. How many kilometers is that, exactly?"
# region: round-trip
# Call 1: the model answers with a function call part instead of text.
# The SDK surfaces it on response.function_calls with args as a dict —
# no json.loads needed, unlike the OpenAI side.
response = client.models.generate_content(
model="gemini-3.1-flash-lite",
contents=QUESTION,
config=CONFIG,
)
fc = response.function_calls[0]
print(f"model wants: {fc.name}({dict(fc.args)})")
result = convert(**fc.args)
# Call 2: replay the conversation — question, the model's function-call
# turn, then a function_response part carrying your result — and the
# model writes the final answer from it.
contents = [
types.Content(role="user", parts=[types.Part(text=QUESTION)]),
response.candidates[0].content,
types.Content(
role="user",
parts=[types.Part.from_function_response(name=fc.name, response={"result": result})],
),
]
final = client.models.generate_content(
model="gemini-3.1-flash-lite",
contents=contents,
config=CONFIG,
)
# endregion
print(final.text)
The wiring difference worth noticing is state. OpenAI lets you chain from
previous_response_id; Gemini has you replay the conversation — your question,
the model's function-call turn, then a function_response part with your
result. Same information, but you assemble it by hand. The Python SDK also has
an automatic mode where you pass a real Python function and the SDK runs the
whole loop for you; skip it until the manual wiring is reflexive, because when
tool calls misbehave in production, the manual version is the one you'll be
debugging in your head.
Put it to work
Three docker-compose web apps under code/showcase/<slug>/, same drill as
every week: bash bootstrap-secrets.sh, docker compose up --build,
http://localhost:3000. Each one gives the model exactly one function and a
job the model cannot do honestly by itself.
Showcase 1 — Unit converter
Ask a conversion question in plain language and the model turns it into a
convert call over exact conversion tables — length, mass, volume, and
temperature with its offset math handled in code. The instructions pin the
model to the tool for arithmetic, and the tool's units are enum-constrained in
the schema, so a hallucinated "furlongs" argument fails validation instead of
producing a made-up factor.
Showcase 2 — Calendar math
Weekdays and day counts are where models quietly guess — they pattern-match
calendars instead of counting. Here the model's only real job is pulling a
YYYY-MM-DD out of your sentence; Python's datetime does the counting and
hands back exact facts. Ask about two dates and the model emits two calls in
one turn, which is why the round-trip code handles a list of calls, not one.
Showcase 3 — Recipe scaler
Paste a recipe, say "make it for twelve," and the model parses the whole ingredient list into a typed array-of-objects argument — the schema shape you haven't seen yet this hour. Python multiplies every quantity exactly and renders 0.75 back as 3/4. A model scaling a recipe in its head drops ingredients and fumbles fractions; a structured call physically can't.
All three run the same backend/main.py, dispatching on PROVIDER, so
PROVIDER=gemini docker compose up --build swaps SDKs without touching the
lesson. The design choice that keeps them honest: every run() has a no-call
branch — if the model decides no function applies, its plain answer comes back
instead of the code faking a conversion that never happened.
Run it
The README in this folder has the Python version, the install line, the two
environment variables, and the exact commands for the basic examples and each
showcase. Keys come from the untracked .env at the course root, same as every
other week.
Takeaways
Function calling is a division of labor: the model chooses, your code executes.
Keep that line sharp. The tool description is a prompt, so write it like one;
the arguments are model output, so validate them like user input; and the
function itself is plain Python you can unit-test without a model anywhere in
sight. Once this round trip is reflexive — call out, result in, answer out —
next week's agent loop is just this loop with a while around it and more than
one tool to choose from.