This openai python sdk function calling tutorial walks you through wiring a local function into a chat completion loop. You’ll define a JSON schema, hand it to the model as a tool, and execute the returned call in your own process—no external webhooks required.
Function calling is not magic: the model emits a structured request, your code runs the function, and you feed the result back. The Python SDK makes this ergonomic if you respect the message shapes.
Prerequisites
- Python 3.10 or newer (we use
**argsunpacking and type hints) openaipackage v1.0.0 or later- An API key from OpenAI or any OpenAI-compatible gateway
Install the SDK and export your key:
pip install "openai>=1.0.0"
export OPENAI_API_KEY="sk-your-key"
If you run this against a different endpoint, set base_url on the client. For example, n4n.ai exposes one OpenAI-compatible endpoint that addresses 240+ models and applies automatic fallback when a provider is rate-limited, which is useful when you don’t want to hardcode a single vendor.
Define the local function
Start with the actual business logic. Keep it side-effect free where possible and return plain serializable data.
def get_weather(lat: float, lon: float) -> dict:
# Stub: in production, call a real weather API
return {"temp_c": 12.4, "condition": "cloudy", "lat": lat, "lon": lon}
The model never sees this code. It only sees the schema you give it next.
Write the tool schema
The SDK expects a list of tools. Each function tool needs a name, description, and a JSON Schema for parameters. Be precise: bad descriptions produce bad arguments.
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Fetch current weather for a latitude/longitude pair",
"parameters": {
"type": "object",
"properties": {
"lat": {"type": "number", "description": "Decimal degrees north"},
"lon": {"type": "number", "description": "Decimal degrees east"}
},
"required": ["lat", "lon"]
}
}
}
]
Note the description fields on each property. The model uses them to decide what to pass. Omitting units is a classic bug.
Initialize the client
Create the client. The default reads OPENAI_API_KEY.
from openai import OpenAI
client = OpenAI()
If you prefer routing across providers with fallback, instantiate with a compatible base URL. The client code stays identical.
# client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="your-gateway-key")
First completion call
Build a minimal conversation and request tool usage.
messages = [
{"role": "user", "content": "What's the weather at 37.77, -122.42?"}
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
tool_choice="auto"
)
assistant_msg = response.choices[0].message
Inspect assistant_msg. When the model decides to call your function, it returns tool_calls:
print(assistant_msg.model_dump_json(indent=2))
Expected truncated output:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"lat\":37.77,\"lon\":-122.42}"
}
}
]
}
If tool_calls is None, the model answered directly. Handle both paths.
Execute the function and feed results back
Parse the arguments, run your local function, and append both the assistant message and a tool result message. The role must be "tool" and you must echo the tool_call_id.
import json
if assistant_msg.tool_calls:
messages.append(assistant_msg) # preserve the model's request
for call in assistant_msg.tool_calls:
if call.function.name == "get_weather":
try:
args = json.loads(call.function.arguments)
data = get_weather(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(data)
})
except (json.JSONDecodeError, TypeError) as e:
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps({"error": str(e)})
})
Do not skip appending assistant_msg to messages. The API rejects a tool message without its preceding assistant call.
Second call for final answer
With the tool result in context, call the model again. You can drop tools here if you only want a synthesized answer.
final = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
)
print(final.choices[0].message.content)
Expected output resembles:
The current weather at coordinates 37.77, -122.42 is cloudy with a temperature of 12.4°C.
That’s the full loop. Everything else is hardening.
Handle parallel tool calls
Modern models may emit multiple tool_calls in one message. Your loop already iterates, but ensure your local functions are safe to run concurrently if latency matters. For CPU-bound stubs, sequential is fine.
# Already handled by the for-loop above; just don't assume len(tool_calls) == 1
If a function is slow, dispatch via concurrent.futures.ThreadPoolExecutor and collect results before appending.
Validate arguments strictly
The model can send {"lat": "37.77"} as a string. JSON Schema validation at the SDK level is not enforced on input. Use pydantic or manual checks:
from pydantic import BaseModel, ValidationError
class WeatherArgs(BaseModel):
lat: float
lon: float
try:
parsed = WeatherArgs(**json.loads(call.function.arguments))
except ValidationError as e:
# return error dict to model so it can self-correct
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps({"error": e.errors()})
})
Returning the validation error as the tool content lets the model retry with corrected types—a pattern that beats crashing the loop.
Streaming tool calls
If you stream, tool_calls arrive in chunks. Accumulate function.arguments across deltas before parsing.
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
stream=True
)
collected = []
for chunk in stream:
delta = chunk.choices[0].delta
if delta.tool_calls:
for tc in delta.tool_calls:
# merge by index into collected
...
This adds complexity; only stream if you need UI responsiveness.
Production considerations
- Set
tool_choiceto{"type": "function", "function": {"name": "..."}}to force a call when you know you need it. - Meter cost: the first and second calls both consume tokens. n4n.ai and similar gateways provide per-token usage metering if you need accounting across models.
- Cache schemas: tool definitions rarely change; define them once at module load.
- Honor provider cache-control hints by forwarding
cache_controlin your request if your gateway supports it.
Complete runnable script
import json
from openai import OpenAI
def get_weather(lat: float, lon: float) -> dict:
return {"temp_c": 12.4, "condition": "cloudy", "lat": lat, "lon": lon}
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Fetch current weather for a latitude/longitude pair",
"parameters": {
"type": "object",
"properties": {
"lat": {"type": "number"},
"lon": {"type": "number"}
},
"required": ["lat", "lon"]
}
}
}
]
client = OpenAI()
messages = [{"role": "user", "content": "What's the weather at 37.77, -122.42?"}]
resp = client.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=tools)
msg = resp.choices[0].message
if msg.tool_calls:
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = get_weather(**args)
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
final = client.chat.completions.create(model="gpt-4o-mini", messages=messages)
print(final.choices[0].message.content)
else:
print(msg.content)
Why not just parse text?
You could ask the model to return JSON in content and json.loads it. Don’t. Function calling separates intent from prose, gives you stable field names, and lets the model self-correct via tool error returns. The openai python sdk function calling tutorial pattern above is the same one you’ll ship; the only variable is the function body.
Function calling is a tight contract between your code and the model. Write the schema like an API spec, execute like a sysadmin, and feed results like a diplomat.