OpenAI function calling lets you give the model structured tools it can invoke, turning free-form text into typed API calls. In this openai function calling example explained, we’ll build a weather assistant that queries a mock API and returns parsed results. You’ll see exact request shapes and the model’s responses at each step.
Prerequisites
- Python 3.10 or newer
openaiPython package (v1.0+)- An API key from OpenAI or any OpenAI-compatible endpoint
- A terminal and a code editor
Set the key in your environment:
export OPENAI_API_KEY="sk-..."
Install the SDK:
pip install openai
Step 1: Define the function schema
The model never calls your code directly. You give it a JSON Schema describing the function, its parameters, and types. Here is a minimal get_weather tool:
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a city",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'Tokyo'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"default": "celsius"
}
},
"required": ["city"]
}
}
}
The schema is strict. If you omit required, the model may skip the argument and your code will error. The description fields are not cosmetic—the model reads them to decide whether to call the function and how to fill arguments. A vague description yields vague calls.
Step 2: Send the first request with tools
We pass the schema in the tools parameter and ask a question that requires it. tool_choice="auto" lets the model decide; you can also force a specific function with tool_choice={"type": "function", "function": {"name": "get_weather"}}.
from openai import OpenAI
import os, json
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}
}
}]
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What's the weather in Paris?"}],
tools=tools,
tool_choice="auto"
)
msg = resp.choices[0].message
print(msg)
Expected output (abbreviated):
ChatCompletionMessage(content=None, tool_calls=[ToolCall(id='call_abc', type='function', function=Function(name='get_weather', arguments='{"city": "Paris", "unit": "celsius"}'))], role='assistant')
The model returned no text. It decided to call get_weather with parsed arguments. That’s the core of this openai function calling example explained: the LLM acts as a structured argument generator.
Step 3: Execute the function locally
You own the execution. Mock the API and validate inputs before trusting them:
def get_weather(city: str, unit: str = "celsius") -> dict:
if not isinstance(city, str) or not city.strip():
raise ValueError("city must be a non-empty string")
fake_db = {"paris": 21, "tokyo": 28}
temp = fake_db.get(city.lower(), 20)
return {"city": city, "temperature": temp, "unit": unit}
Parse the arguments the model sent:
import json
if msg.tool_calls:
call = msg.tool_calls[0]
args = json.loads(call.function.arguments)
result = get_weather(**args)
print(result)
Output:
{'city': 'Paris', 'temperature': 21, 'unit': 'celsius'}
Step 4: Feed the result back
You must append two messages: the assistant’s tool_calls message (verbatim) and a tool message carrying the result and the matching tool_call_id. Order matters—the assistant message must precede the tool message.
messages = [
{"role": "user", "content": "What's the weather in Paris?"},
msg, # assistant message with tool_calls
{
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result)
}
]
followup = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools
)
print(followup.choices[0].message.content)
Expected final output:
The current temperature in Paris is 21°C (celsius).
The model synthesized the tool result into a natural language answer. Our openai function calling example explained so far covers the single-call happy path.
Step 5: Handle multiple calls and loops
Production code needs a loop. The model can request several tools in one turn. Iterate over msg.tool_calls:
def run_conversation(user_input: str):
messages = [{"role": "user", "content": user_input}]
while True:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools
)
msg = resp.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
try:
res = get_weather(**args)
except Exception as e:
res = {"error": str(e)}
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(res)
})
This loop terminates when the model stops emitting tool_calls. It’s the skeleton every function-calling agent needs.
Why not just prompt for JSON?
You could ask the model to output {"city": "Paris"} and parse it. That breaks constantly: the model adds markdown fences, commentary, or omits a comma. Function calling moves schema enforcement to the API boundary. The arguments arrive as a string you json.loads without regex gymnastics. You also keep the freedom to swap models—the same tools array works across any chat model that supports the feature.
Designing descriptions that work
The description on the function and each parameter is the only context the model has. “Get weather” is weaker than “Get current temperature for a city using simulated data; returns integer degrees.” Add examples inside parameter descriptions: "city": "e.g. 'Tokyo' or 'New York'". In testing, better descriptions cut spurious calls by more than half.
Common pitfalls
Forgetting tool_call_id
If you drop tool_call_id from the tool message, the API rejects the request. The ID binds the result to the specific call.
Trusting arguments blindly
The model can send "city": 123. Validate with pydantic or jsonschema before calling your backend.
Streaming
With stream=True, tool_calls arrive in fragments. You must accumulate delta.tool_calls and reconstruct the arguments string before parsing.
# Pseudo-pattern for streaming
tool_buffer = {}
for chunk in stream:
for tc in chunk.choices[0].delta.tool_calls or []:
tool_buffer.setdefault(tc.index, {"id": tc.id, "name": "", "args": ""})
if tc.id: tool_buffer[tc.index]["id"] = tc.id
if tc.function.name: tool_buffer[tc.index]["name"] += tc.function.name
if tc.function.arguments: tool_buffer[tc.index]["args"] += tc.function.arguments
Hidden state
The assistant message with tool_calls must be sent back exactly as received. If you rebuild it from scratch, the id and type fields must match or the API errors.
Using an OpenAI-compatible gateway
If you point the client at n4n.ai’s OpenAI-compatible endpoint, the same code accesses 240+ models and gets automatic fallback when a provider is rate-limited. Set base_url="https://api.n4n.ai/v1" and keep your existing tools schema. The gateway forwards provider cache-control hints, so repeated schemas cost fewer tokens.
client = OpenAI(
api_key=os.environ["N4N_API_KEY"],
base_url="https://api.n4n.ai/v1"
)
No other changes required. The openai function calling example explained above runs unchanged across providers.
Wrapping up
Function calling is not magic: you expose a schema, the model emits arguments, you execute, and you return data. The openai function calling example explained here is the minimal viable loop. Build error handling and validation around it, and you have a robust tool-using system that degrades gracefully instead of hallucinating API requests.