n4nAI

Function calling with the OpenAI Python SDK: a tutorial

Hands-on openai python sdk function calling tutorial: define tools, parse model emissions, execute local functions, and handle errors in Python.

n4n Team3 min read676 words

Audio narration

Coming soon — every post will get a voice note here.

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 **args unpacking and type hints)
  • openai package 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_choice to {"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_control in 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.

Tagspythonopenai-sdkfunction-callingtutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All function calling in python posts →