Sending your first function calling api request is less about memorizing an endpoint and more about getting the message loop right. This tutorial builds a working example from scratch against the OpenAI-compatible Chat Completions API, showing how to define a tool, trigger a model call, execute the function, and feed the result back so the model can produce a final answer.
Prerequisites
- Python 3.10 or newer.
- The
openaiPython package:pip install openai>=1.0.0. - An API key from any provider that exposes the OpenAI-compatible
/v1/chat/completionsendpoint. If you need one URL that fronts many models, n4n.ai provides an OpenAI-compatible gateway covering 240+ models with automatic fallback when a provider is rate-limited. - A terminal and basic comfort with JSON and Python dictionaries.
No framework required. We use the raw SDK so you can see every field the protocol demands.
Step 1: Define the function schema
Function calling works by sending the model a JSON Schema description of functions it can call. The model does not execute code; it returns arguments shaped to that schema. The schema is passed under the tools parameter and must conform to a restricted JSON Schema dialect.
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and state, e.g. San Francisco, CA"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"default": "celsius"
}
},
"required": ["location"]
}
}
}
The parameters block is a standard JSON Schema subset. Keep it strict: the model will follow required and enum closely. Avoid ambiguous descriptions—they translate directly into model behavior. If you later generate this from Python types (e.g., pydantic), you remove an entire class of drift bugs.
Step 2: Send the initial request
Configure the SDK with your base URL and key. Then call chat.completions.create with the tools argument. Use a model that supports function calling; most current ones do. The tool_choice="auto" directive lets the model decide whether to call a tool or answer directly.
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["API_KEY"],
base_url="https://api.n4n.ai/v1", # any OpenAI-compatible endpoint
)
response = client.chat.completions.create(
model="openai/gpt-4o-mini",
messages=[
{"role": "user", "content": "What's the weather in Paris?"}
],
tools=[{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}
}
}],
tool_choice="auto",
)
msg = response.choices[0].message
print(msg)
Expected output (abbreviated):
ChatCompletionMessage(content=None, role='assistant', tool_calls=[ToolCall(id='call_abc', type='function', function=Function(arguments='{"location":"Paris","unit":"celsius"}', name='get_weather'))])
The model returned tool_calls instead of text. That is the core of your first function calling api request: the API responded with a structured call, not prose. If content is not None and tool_calls is empty, the model chose to answer directly—handle both branches.
Step 3: Execute the function locally
You now act as the runtime. Parse the arguments and return a result. In production this hits your DB or an external API; here we stub it. Never trust the model to validate types—do it yourself.
import json
if msg.tool_calls:
call = msg.tool_calls[0]
try:
args = json.loads(call.function.arguments)
except json.JSONDecodeError:
args = {}
# Minimal validation
if "location" not in args:
raise ValueError("Model omitted required location")
print("Model asked for:", args)
def get_weather(location, unit="celsius"):
# Pretend we looked it up
return {"location": location, "temp": 21, "unit": unit}
result = get_weather(**args)
print("Function output:", result)
Checkpoint output:
Model asked for: {'location': 'Paris', 'unit': 'celsius'}
Function output: {'location': 'Paris', 'temp': 21, 'unit': 'celsius'}
Step 4: Feed the result back
The model needs the tool result to produce a final answer. Append the assistant message (with tool_calls) and a new message with role: "tool". The tool_call_id must match the call you executed. The content is a string—usually JSON, but any string the model can parse works.
followup = client.chat.completions.create(
model="openai/gpt-4o-mini",
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),
},
],
tools=[{"type": "function", "function": {
"name": "get_weather",
"description": "Get current temperature for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}
}}],
)
print(followup.choices[0].message.content)
Expected final output:
The current temperature in Paris is 21°C (celsius).
You have completed a full round trip. The first function calling api request triggered a tool, your code ran it, and the model synthesized the answer from the returned data.
Step 5: Handle the messy parts
Real systems deviate from the happy path. A few hard-won notes from shipping this in production:
- Missing required args: If the model omits a
requiredfield, some providers error; others return a call with null. Validateargsbefore calling your function and return atoolmessage with an error string if needed—the model can often recover. - Parallel calls:
tool_callsis a list. Loop over it and execute each independently, then return onetoolmessage per call with the correcttool_call_id. - Timeouts: Wrap external calls with
asyncio.wait_foror a sync timeout. The model doesn’t know your DB is slow; a hung call blocks the whole turn. - Schema drift: Keep your local function signature and the JSON Schema in sync. Generate the schema from pydantic if you can; hand-written schemas rot.
- Provider fallback: If you point at a gateway like n4n.ai, a degraded upstream gets retried automatically, but your
tool_choiceand cache-control hints are still forwarded—don’t assume the same model answered. Log themodelfield from the response. - Streaming: When streaming,
tool_callsarrive in fragments. Accumulatefunction.argumentsacross chunks before parsing.
Why function calling beats prompt parsing
You could ask the model to emit JSON and parse it yourself. Don’t. Function calling gives you:
- Native
requiredenforcement by the model training. - A clean separation between conversation history and tool results.
- Provider-side optimizations—some run constrained decoding to guarantee valid arguments.
The cost is an extra round trip and stricter schema discipline. For any system beyond a demo, that trade is worth it.
Full runnable script
import os
import json
from openai import OpenAI
client = OpenAI(
api_key=os.environ["API_KEY"],
base_url="https://api.n4n.ai/v1",
)
TOOL = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}
}
}
def get_weather(location, unit="celsius"):
return {"location": location, "temp": 21, "unit": unit}
# 1. Initial request
resp = client.chat.completions.create(
model="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "What's the weather in Paris?"}],
tools=[TOOL],
)
assistant_msg = resp.choices[0].message
# 2. Execute
if not assistant_msg.tool_calls:
raise SystemExit("Model didn't call a tool")
call = assistant_msg.tool_calls[0]
args = json.loads(call.function.arguments)
tool_result = get_weather(**args)
# 3. Follow-up
final = client.chat.completions.create(
model="openai/gpt-4o-mini",
messages=[
{"role": "user", "content": "What's the weather in Paris?"},
assistant_msg,
{"role": "tool", "tool_call_id": call.id, "content": json.dumps(tool_result)},
],
tools=[TOOL],
)
print(final.choices[0].message.content)
Run with API_KEY=sk-... python script.py. You should see the temperature sentence. That script is the minimal viable pattern; everything else is retry logic and schema management.
What you just built
You defined a tool, sent a first function calling api request, executed the function server-side, and closed the loop with a tool message. That pattern—model proposes, you dispose—is the backbone of every agentic integration. From here, add more tools, validate schemas with pydantic, batch parallel calls, and log the resolved model name for observability.