Function calling with the openai python sdk function calling turns a language model from a text generator into a structured action dispatcher. The pattern is stable across providers that expose the OpenAI-compatible chat completions interface: you declare callable tools, the model emits arguments as JSON, and your code executes the side effect.
Install and point the client
The official SDK is openai. Pin a recent major version; the tools parameter has been stable since 1.0.
pip install "openai>=1.0"
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from env
If you proxy through a gateway, set base_url. The rest of this guide is identical because the request shape is the contract.
Define tools as JSON Schema
A tool is a dictionary with type: "function" and a function block. The parameters field is a JSON Schema object. Keep it tight—every key is sent on every request.
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current temperature for a given location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}
}
}
]
Avoid verbose descriptions. The model reads them, but so does your context window and your bill.
Send the first request
Set tools and let the model decide with tool_choice="auto". Use "none" to suppress calls, or force a specific tool with {"type":"function","function":{"name":"get_weather"}}.
messages = [{"role": "user", "content": "What's the weather in Berlin?"}]
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
tool_choice="auto"
)
msg = resp.choices[0].message
If the model wants to call a tool, msg.tool_calls is a list. Each item has id, function.name, and function.arguments (a JSON string).
Execute and feed results back
You must append the assistant message (with tool_calls) to the conversation before appending the tool result. Skip this and the API rejects the request.
import json
def get_weather(location: str, unit: str = "celsius") -> dict:
# stub: real impl hits a weather API
return {"temp": 22, "unit": unit}
if msg.tool_calls:
messages.append(msg) # preserve the assistant turn
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)
})
The role: "tool" message requires tool_call_id matching the call. Content must be a string—serialize dicts yourself.
Build the agent loop
One call rarely finishes the task. Wrap the exchange in a bounded loop.
MAX_STEPS = 5
for _ in range(MAX_STEPS):
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools
)
msg = resp.choices[0].message
if not msg.tool_calls:
print(msg.content)
break
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)
})
else:
raise RuntimeError("exceeded MAX_STEPS")
Always cap iterations. Models will happily call get_weather in a circle if your stub returns the same data.
Streaming tool calls
Streaming works, but tool_calls arrive as deltas. Accumulate function.arguments per id before parsing.
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
stream=True
)
buffer = {}
for chunk in stream:
delta = chunk.choices[0].delta
if delta.tool_calls:
for tc in delta.tool_calls:
buf = buffer.setdefault(tc.id, {"name": "", "args": ""})
if tc.function.name:
buf["name"] += tc.function.name
if tc.function.arguments:
buf["args"] += tc.function.arguments
After the stream ends, parse each buf["args"] with json.loads. Do not assume the JSON is complete mid-stream.
Common pitfalls and tradeoffs
Schema drift and strict mode
Models occasionally emit keys not in your schema. If your backend uses Pydantic, validate after parsing and return a tool error message rather than crashing. Some providers support strict tool schemas that constrain the model to the exact shape; enable it when available.
Parallel calls and partial failure
A single assistant message can contain multiple tool_calls. Execute them concurrently, but append each result with its own tool_call_id. If one fails, return an error string for that id—the model can often recover.
# pseudo-concurrent
results = asyncio.gather(*[run_call(c) for c in msg.tool_calls])
Token overhead and latency
Every tool definition is resent each turn. Five tools with rich descriptions can add 300–800 tokens per request. Trim descriptions, share schemas across calls, and drop unused tools dynamically based on conversation state.
Routing and resilience
If you front requests with an OpenAI-compatible endpoint such as n4n.ai, automatic fallback when a provider is rate-limited or degraded keeps the agent loop running, and per-token usage metering exposes how much your schemas cost. The SDK code above does not change; just set base_url. Still, ensure your tool schemas are portable—not every model supports the same strictness flags.
Production checklist
- Validate
argumentswith a schema loader before function execution. - Never trust
tool_call_iduniqueness across separate requests; scope it to the conversation. - Log the full
messagesarray on failure; the assistanttool_callsturn is the usual missing piece. - Set
temperature=0for deterministic tool selection when the task is mechanical. - Cache tool definitions in memory; rebuilding them per call wastes cycles.
Function calling with the openai python sdk function calling is mostly disciplined bookkeeping. Get the message order right, bound the loop, and treat the schema as paid payload. The rest is application logic.