This function calling tutorial schema to result shows how to take a JSON Schema, send it to an LLM, and run the returned call against real code. We build a working Python pipeline that talks to an OpenAI-compatible chat endpoint and executes the tool locally, then feeds the result back for a natural language answer.
Prerequisites
- Python 3.10 or newer
openaiPython package (pip install openai)- An API key for an OpenAI-compatible endpoint, exported as
OPENAI_API_KEY. If you use a gateway, setOPENAI_BASE_URLto its OpenAI-compatible base URL. jsonschemaorpydanticfor validation (optional but recommended)- Basic comfort with JSON Schema draft-07
Define the function schema
Function calling starts with a precise contract. The model sees a tools array where each entry is a function definition. The parameters field is a standard JSON Schema object. Be explicit: describe every property and mark required fields.
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a given 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"]
}
}
}
A vague description breeds bad arguments. Spend time on the description strings; they are part of the prompt.
Send the schema to the model
We use the official openai client. Point it at any compatible endpoint. The tools parameter carries our schema; tool_choice="auto" lets the model decide.
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
)
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a given location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}
}
}]
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What's the weather in Paris?"}],
tools=tools,
tool_choice="auto"
)
msg = resp.choices[0].message
print(msg.tool_calls)
Expected output (id truncated):
[ChatCompletionMessageToolCall(id='call_123', type='function', function=Function(arguments='{"location":"Paris","unit":"celsius"}', name='get_weather'))]
If msg.tool_calls is None, the model answered directly. In this function calling tutorial schema to result we focus on the tool path.
Parse and validate arguments
The model returns a JSON string inside arguments. Parse it, then validate. Never assume the shape is correct.
import json
def parse_tool_call(tool_call):
if tool_call.function.name != "get_weather":
raise ValueError(f"unexpected tool: {tool_call.function.name}")
args = json.loads(tool_call.function.arguments)
if "location" not in args:
raise ValueError("missing required field: location")
if args.get("unit") not in (None, "celsius", "fahrenheit"):
raise ValueError("invalid unit")
args.setdefault("unit", "celsius")
return args
args = parse_tool_call(msg.tool_calls[0])
print(args)
Output:
{'location': 'Paris', 'unit': 'celsius'}
For production, replace the manual checks with a pydantic model. It gives clearer errors and serializes back to JSON easily.
Execute the function locally
The whole point is to run real code. Here is a mock that stands in for an HTTP weather API.
import random
def get_weather(location: str, unit: str) -> float:
# Pretend we called an external service
temp_c = random.uniform(10.0, 25.0)
if unit == "fahrenheit":
return temp_c * 9/5 + 32
return temp_c
result = get_weather(**args)
print(f"{result:.1f} {args['unit']}")
Sample output:
18.3 celsius
Swap the mock for requests.get to your internal service. Keep the function side-effect free where possible; pass all context via arguments.
Return the result to the model
The model needs the tool result to compose a final answer. Append three messages: the original user turn, the assistant message containing tool_calls, and a role: "tool" message referencing the call id.
messages = [
{"role": "user", "content": "What's the weather in Paris?"},
msg,
{
"role": "tool",
"tool_call_id": msg.tool_calls[0].id,
"content": json.dumps({"temperature": result, "unit": args["unit"]})
}
]
final = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools
)
print(final.choices[0].message.content)
Expected final answer:
The current temperature in Paris is 18.3°C.
This closes the loop. The function calling tutorial schema to result is now a round trip.
Handle errors and fallback
Models hallucinate. Wrap execution so a bad call returns structured error instead of crashing the loop.
try:
args = parse_tool_call(msg.tool_calls[0])
result = get_weather(**args)
tool_content = json.dumps({"temperature": result, "unit": args["unit"]})
except Exception as e:
tool_content = json.dumps({"error": str(e)})
messages.append({
"role": "tool",
"tool_call_id": msg.tool_calls[0].id,
"content": tool_content
})
If you route through a gateway that provides automatic fallback when a provider is rate-limited or degraded, the second create call will still succeed without code changes. n4n.ai does this on its OpenAI-compatible endpoint, but the client code stays identical.
Multiple tools and parallel calls
Real apps expose several functions. Add more entries to tools. The model can emit multiple tool_calls in one message. Execute them concurrently:
import concurrent.futures
def dispatch(tool_call):
args = parse_tool_call(tool_call)
return tool_call.id, json.dumps({"temperature": get_weather(**args)})
with concurrent.futures.ThreadPoolExecutor() as ex:
results = list(ex.map(dispatch, msg.tool_calls))
for call_id, content in results:
messages.append({"role": "tool", "tool_call_id": call_id, "content": content})
Always match each result to its tool_call_id. Mismatches cause API errors.
Production considerations
- Schema caching: Build
toolsonce at startup. Rebuilding per request wastes tokens and CPU. - Strict validation: Use pydantic v2 with
model_validate_jsonfor speed. - Streaming: Pass
stream=Trueon the final call to render the answer token by token. - Metering: If your gateway does per-token usage metering, log
resp.usage.total_tokensto track cost per tool loop. - Routing hints: Some gateways, including n4n.ai, honor client routing directives and forward provider cache-control hints. You can pin a model or reuse a cached prompt prefix without altering your schema code.
Function calling is not magic: it is a strict request/response protocol with JSON Schema as the contract. Get the schema right, validate every argument, and execute locally with the same discipline you apply to any API boundary.
Test the loop
A tiny test guards against schema drift:
def test_parse():
tc = type("TC", (), {"function": type("F", (), {
"name": "get_weather",
"arguments": '{"location":"Berlin"}'
})})()
args = parse_tool_call(tc)
assert args == {"location": "Berlin", "unit": "celsius"}
Run pytest -q. Green means your function calling tutorial schema to result pipeline is reproducible and safe to ship.
That’s the entire path from schema to executed result.