Function calling turns an LLM from a text generator into a controller for your code. This tutorial walks through python function calling n4n.ai api using the OpenAI-compatible chat completions endpoint, so you can wire model-selected tools into real Python functions without rewriting your stack. You will define a tool, send a request, parse the model’s call, execute local code, and feed the result back for a final answer.
Prerequisites
- Python 3.10 or newer
openaiPython SDK v1.0 or later- A gateway API key exported as
N4N_API_KEY python-dotenvif you load env vars from a file
pip install openai python-dotenv
Create a .env file in your project root:
echo "N4N_API_KEY=sk-your-key" > .env
You should already know basic Python and the shape of a chat completion request. No prior experience with function calling is required.
Configure the OpenAI-compatible client
The gateway exposes a single OpenAI-compatible base URL. You point the standard OpenAI client at it and keep the rest of your code unchanged.
from openai import OpenAI
from dotenv import load_dotenv
import os
load_dotenv()
client = OpenAI(
base_url="https://api.n4n.ai/v1",
api_key=os.environ["N4N_API_KEY"],
)
The chat.completions.create method behaves exactly as the OpenAI SDK documents. The only difference is that the model field accepts any of the 240+ models routed by the gateway, and you can pass provider-specific hints through the same parameters.
Define a tool schema
Tools are declared as JSON Schema objects inside the tools parameter. The model sees the name, description, and parameter definitions, but never your implementation. Write tight descriptions—they directly affect whether the model calls the tool correctly.
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Fetch current temperature for a given city",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'Berlin' or 'Paris'",
}
},
"required": ["city"],
},
},
}
]
Avoid ambiguous parameter names. If a tool needs units, encode them as an enum rather than free text.
Send the first request
Set tool_choice="auto" to let the model decide whether to call a tool. Use a fast model for the demo loop.
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "What's the temperature in Paris?"}],
tools=tools,
tool_choice="auto",
)
message = response.choices[0].message
print(message)
Expected checkpoint output (abridged):
ChatCompletionMessage(content=None, tool_calls=[ToolCall(id='call_abc123', type='function', function=Function(arguments='{"city": "Paris"}', name='get_weather'))], role='assistant')
When content is None and tool_calls is populated, the model is asking your code to run something. If the model instead returns content with text, it chose not to use a tool.
Parse and dispatch
Extract the function name and arguments, then map to a local Python callable. Never use eval on the arguments—always json.loads.
import json
def get_weather(city: str) -> str:
# Stub: replace with a real HTTP request to a weather API
return f"12°C and overcast in {city}"
if message.tool_calls:
for call in message.tool_calls:
if call.function.name == "get_weather":
args = json.loads(call.function.arguments)
result = get_weather(args["city"])
print("TOOL RESULT:", result)
Output:
TOOL RESULT: 12°C and overcast in Paris
In production, validate args against your own schema before calling the function. Models occasionally omit required fields or emit malformed JSON.
Feed the result back
The model needs the tool result to produce a natural language answer. Append the assistant message (which contains tool_calls) and a role: "tool" message carrying the result and the matching tool_call_id.
follow_up = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "user", "content": "What's the temperature in Paris?"},
message,
{
"role": "tool",
"tool_call_id": message.tool_calls[0].id,
"content": result,
},
],
tools=tools,
)
print(follow_up.choices[0].message.content)
Expected final output:
The temperature in Paris is currently 12°C and overcast.
The tool role is mandatory for the second turn. Omitting it raises a validation error from the API.
Build a reusable conversation loop
Wrap the exchange in a loop so the model can chain multiple tools across turns. Stop when the response contains no tool_calls.
def run_conversation(user_prompt: str, model: str = "gpt-3.5-turbo", max_steps: int = 5):
msgs = [{"role": "user", "content": user_prompt}]
for _ in range(max_steps):
resp = client.chat.completions.create(
model=model, messages=msgs, tools=tools, tool_choice="auto"
)
msg = resp.choices[0].message
msgs.append(msg)
if not msg.tool_calls:
return msg.content
for call in msg.tool_calls:
try:
args = json.loads(call.function.arguments)
except json.JSONDecodeError:
args = {}
if call.function.name == "get_weather":
tool_out = get_weather(args.get("city", "unknown"))
else:
tool_out = "unknown tool"
msgs.append({
"role": "tool",
"tool_call_id": call.id,
"content": tool_out,
})
return "Reached max steps without a final answer"
print(run_conversation("Compare weather in Paris and Berlin."))
This loop handles multi-step plans. The model may call get_weather twice, receive both results, then synthesize a comparison in the final text response.
Designing safe tool schemas
Treat every tool as a privileged entry point. Follow these rules:
- Keep descriptions declarative and explicit about side effects.
- Use enums for constrained inputs instead of free strings.
- Never expose filesystem or shell primitives directly; wrap them with allowlists.
- Return strings from local functions; the API expects
contentas text.
A well-shaped schema reduces the chance the model hallucinates parameters and reduces parsing errors in your dispatch code.
Inspecting usage metadata
Every completion response includes a usage object with token counts. Because the gateway meters per-token usage, you can log response.usage to track cost across model switches.
print(response.usage)
# CompletionUsage(prompt_tokens=54, completion_tokens=12, total_tokens=66)
If you set stream=True, usage arrives in the final chunk. For function calling, streaming adds complexity because tool-call arguments arrive in fragments; buffer them before parsing.
Provider resilience without code changes
The python function calling n4n.ai api sits in front of multiple upstream providers. If the primary model is rate-limited or degraded, the gateway performs automatic fallback to a healthy equivalent and forwards your provider cache-control hints. Your tools schema, parsing logic, and conversation loop stay identical; you only swap the model string if you want to force a specific route.
This matters when you ship a tool-using agent to production: a single vendor outage should not require a code deploy.
Error handling notes
- Validate
argumentswith pydantic or jsonschema before calling local functions. - Catch
json.JSONDecodeError—models occasionally emit trailing commas or truncated JSON. - Set a max iteration count in the loop to avoid infinite tool chains.
- Use
client.with_options(timeout=10)for strict latency budgets on each call.
try:
args = json.loads(call.function.arguments)
except json.JSONDecodeError:
args = {}
Also handle openai.APIStatusError to detect 4xx/5xx responses and retry with a different model if your logic requires it.
Wrapping up
You now have a minimal, robust pattern: declare tools as JSON Schema, let the model emit tool_calls, execute them locally, and return results via role: "tool" messages. The OpenAI-compatible surface means the same code works against the gateway or directly against a provider, and the routing fallback reduces operational toil when a single vendor hiccups.
Checkpoint summary:
- Client configured with gateway base URL.
- Tool schema defined and passed in
tools. - Model returns
tool_callswith structured arguments. - Local function executes and result fed back with
tool_call_id. - Final assistant message contains the synthesized answer.
Extend get_weather with a real HTTP call, add more tools, and you have a working agent. The mechanics do not change as you scale to dozens of functions—only your dispatch table and schema quality need attention.