Multi-tool function calling python gpt-4o changes how you build agentic flows: instead of chaining single calls, the model can request several independent operations in one response. This tutorial builds a runnable example that calls a mock weather service and a mock calendar in parallel, then merges the results into a single natural language answer.
Prerequisites
- Python 3.10 or newer
openaiPython package (v1.0+)- An API key for GPT-4o (or an OpenAI-compatible gateway)
- Basic familiarity with sync Python; no async required
Set up the environment and install the SDK:
pip install openai
export OPENAI_API_KEY="sk-..."
If you use a gateway, the key and base URL differ, but the client code does not.
Design the tool schemas
GPT-4o consumes JSON Schema fragments attached to the tools parameter. Keep them tight and unambiguous. We define two functions: get_weather and get_calendar_events.
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature and conditions for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. 'Berlin'"}
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "get_calendar_events",
"description": "List calendar events for a given date",
"parameters": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "ISO date YYYY-MM-DD"}
},
"required": ["date"],
},
},
},
]
Schema drift is the most common source of production bugs. Validate the model’s arguments with pydantic in real code; for this tutorial we trust json.loads.
Implement the Python functions
The model only sees the schemas. Your code executes the logic. Use mocks so the example runs offline.
def get_weather(city: str) -> dict:
# In production, call a real weather API.
return {"city": city, "temp_c": 21, "condition": "partly cloudy"}
def get_calendar_events(date: str) -> dict:
# In production, query a calendar service.
return {"date": date, "events": ["Standup at 09:00", "1:1 at 14:00"]}
First model call
Initialize the client and send a prompt that clearly needs both tools.
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
messages = [
{"role": "user", "content": "What's the weather in Berlin and what's on my calendar for 2024-06-01?"}
]
resp = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message
print(msg.tool_calls)
Expected output (IDs truncated):
[
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"Berlin\"}"
}
},
{
"id": "call_def456",
"type": "function",
"function": {
"name": "get_calendar_events",
"arguments": "{\"date\": \"2024-06-01\"}"
}
}
]
The model returned two tool_calls in one assistant message. That is the core of multi-tool function calling python gpt-4o: a single inference step fans out to multiple backend operations.
Execute and dispatch
You must append the assistant message (with tool_calls) to the conversation before adding tool results. Each result message references the tool_call_id from the call it answers.
import json
messages.append(msg) # assistant message containing tool_calls
for call in msg.tool_calls:
fn_name = call.function.name
args = json.loads(call.function.arguments)
if fn_name == "get_weather":
result = get_weather(**args)
elif fn_name == "get_calendar_events":
result = get_calendar_events(**args)
else:
result = {"error": "unknown function"}
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})
Order of tool messages does not matter as long as the IDs match. The model correlates them by ID, not position.
Second call to synthesize
Now ask the model to produce the final answer using the tool outputs. Passing tools again lets it call more if needed; in practice it returns text.
final = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)
answer = final.choices[0].message.content
print(answer)
Expected output:
In Berlin it's currently 21°C and partly cloudy. On 2024-06-01 you have: Standup at 09:00, 1:1 at 14:00.
That is a complete multi-tool round trip. The model decided both calls were required, your code ran them, and the model merged the data into a coherent reply.
Parallel execution
The loop above runs tools sequentially. They are independent, so parallelize with threads or asyncio. The only hard constraint is matching tool_call_id values.
from concurrent.futures import ThreadPoolExecutor
def run_call(call):
args = json.loads(call.function.arguments)
if call.function.name == "get_weather":
return call.id, get_weather(**args)
if call.function.name == "get_calendar_events":
return call.id, get_calendar_events(**args)
with ThreadPoolExecutor() as ex:
results = ex.map(run_call, msg.tool_calls)
for call_id, res in results:
messages.append({
"role": "tool",
"tool_call_id": call_id,
"content": json.dumps(res),
})
For CPU-bound or async-native tools, swap ThreadPoolExecutor for asyncio.gather. The message shape stays identical.
Common pitfalls
Strict schema validation bites fast. GPT-4o occasionally emits unicode escapes or trailing whitespace in arguments; json.loads handles it, but add pydantic for type safety.
If you set tool_choice to a specific function name, multi-tool is disabled. Use "auto" (or omit) to let the model pick zero, one, or many.
Verbose schema descriptions inflate token usage on every call. Write descriptions that are minimal but unambiguous—“City name, e.g. ‘Berlin’” is enough.
Tool results should be compact JSON. Large payloads (full HTML pages, long lists) blow up context and cost. Filter server responses before returning them.
Routing through a gateway
If you deploy behind an inference gateway such as n4n.ai, the same OpenAI-compatible client works by setting base_url. You get automatic fallback when a provider is rate-limited or degraded, without changing the function-calling logic above.
client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="your-key")
The rest of the multi-tool function calling python gpt-4o code stays identical; only the endpoint and key differ.
Full script
from openai import OpenAI
import json
from concurrent.futures import ThreadPoolExecutor
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature and conditions for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "get_calendar_events",
"description": "List calendar events for a given date",
"parameters": {
"type": "object",
"properties": {"date": {"type": "string"}},
"required": ["date"],
},
},
},
]
def get_weather(city: str) -> dict:
return {"city": city, "temp_c": 21, "condition": "partly cloudy"}
def get_calendar_events(date: str) -> dict:
return {"date": date, "events": ["Standup at 09:00", "1:1 at 14:00"]}
client = OpenAI()
messages = [{"role": "user", "content": "What's the weather in Berlin and what's on my calendar for 2024-06-01?"}]
resp = client.chat.completions.create(model="gpt-4o", messages=messages, tools=tools)
msg = resp.choices[0].message
messages.append(msg)
def run_call(call):
args = json.loads(call.function.arguments)
if call.function.name == "get_weather":
return call.id, get_weather(**args)
if call.function.name == "get_calendar_events":
return call.id, get_calendar_events(**args)
with ThreadPoolExecutor() as ex:
for call_id, res in ex.map(run_call, msg.tool_calls):
messages.append({"role": "tool", "tool_call_id": call_id, "content": json.dumps(res)})
final = client.chat.completions.create(model="gpt-4o", messages=messages, tools=tools)
print(final.choices[0].message.content)
Multi-tool function calling python gpt-4o is the cheapest way to cut latency in agent loops: let the model fan out, then gather. Ship argument validation before you ship the demo.