AI Weather Assistant with Function Calling
Build an AI assistant that calls your custom functions and returns typed data.
Introduction
30 Second Summary
When you ask ChatGPT about the weather or your calendar, it doesn't just guess. It calls external tools to get real data, then uses that data to write you a helpful answer.
In this project, you will build an AI weather assistant using Python and the OpenAI API. Your assistant will autonomously decide when to call your custom weather function and return structured data you can use in any application.
What You'll Build
An AI-powered weather assistant that receives natural language questions, autonomously calls your custom Python functions, and returns typed weather reports using Pydantic models.
By the end of this project, you'll have:
- A configured Python environment with the OpenAI SDK connected to the API.
- A complete function calling flow where the AI model decides when to call your custom tool and uses the result to answer questions.
- Structured outputs using Pydantic models that give you typed, validated data for any application.
- Secret Mission: Add a second tool and watch the model intelligently choose between multiple tools.
Are there any prerequisites?
You should be comfortable with basic Python concepts like variables, functions, dictionaries, and f-strings. No AI or API experience is needed.
OpenAI requires a minimum $5 top-up on your account to access Tier 1 API access. After that, this project costs less than $0.01 total to complete.
Set Up Your Environment
Before you can build an AI assistant, you need the right tools in place. Your code needs a way to talk to the OpenAI API, and your API key needs to stay secure.
In this step, you will create a new project in PyCharm, install the required Python packages, configure your API key, and verify that everything connects successfully.
In this step, get ready to:
- Install the OpenAI SDK and python-dotenv packages.
- Create a secure environment file for your API key.
- Verify a successful connection to the OpenAI API.
Install Python packages
Your project needs two packages: the OpenAI SDK (to communicate with the API) and python-dotenv (to load your API key from a secure file).
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type PyCharm and press Enter to open it.
- On the Welcome screen, click New Project.
- In the Location field, change the project name at the end of the path to weather-assistant.
- Keep the default Project venv option selected for the interpreter.
- Click Create.
What is a virtual environment?
A virtual environment (venv) is an isolated space for your project's packages. Packages you install here won't interfere with other Python projects on your machine.
PyCharm creates one automatically when you start a new project with the Project venv option.
- Open the built-in terminal by clicking View in the top menu bar, then Tool Windows, then Terminal.
- Install both packages by running this command:
pip install openai python-dotenv
What does this command do?
This installs two packages into your project's virtual environment.
- openai is the official Python SDK for communicating with the OpenAI API.
- python-dotenv lets you load environment variables from a .env file so your API key stays out of your code.
- Verify the installation by running this command:
python -c "import openai; print(openai.__version__)"
- You should see a version number starting with 2 (e.g., 2.41.1).
Seeing an error?
- Make sure the terminal is using your project's virtual environment. You should see (venv) or (.venv) at the start of your terminal prompt.
- If you see "No module named openai", try closing and reopening the terminal from View > Tool Windows > Terminal to reload the environment.
Why isn't pip installing to my virtual environment?
Get your OpenAI API key and configure environment
The OpenAI API requires an API key for authentication. You will store this key in a special file that your code can read but that never gets shared publicly.
Cost note
OpenAI requires a minimum $5 top-up to access API Tier 1. After that, this project costs less than $0.01 total using gpt-4o-mini ($0.15 per million input tokens).
- Open https://platform.openai.com/api-keys in your browser.
- Click Create new secret key.
- Give it a name (e.g., weather-assistant) and click Create secret key.
- Copy the key immediately. You will not be able to see it again after closing this dialog.
Keep your key private
Never paste your API key directly into code files or share it publicly. The .env file you are about to create keeps it separate from your source code.
- In PyCharm's Project tool window on the left, right-click the weather-assistant project root.
- Select New, then File.
- Type .env as the file name and press Enter.
- Paste the following into your new .env file, replacing your-api-key-here with the key you just copied:
OPENAI_API_KEY=your-api-key-here
What does this file do?
The OPENAI_API_KEY variable is read automatically by the OpenAI SDK when you initialize the client. The python-dotenv package loads this file into your environment so the SDK can find it.
- Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).
PyCharm asking about adding .env to Git?
If PyCharm prompts you to add .env to version control, click Cancel or No. You never want API keys tracked in Git.
How do I prevent my .env file from being committed to Git?
Verify API connection
Now that your key is stored, you will write a short script to confirm that everything is wired up correctly.
- Right-click the weather-assistant project root in the Project tool window.
- Select New, then Python File.
- Name it test_connection and press Enter.
- Paste the following code into test_connection.py:
import os
from dotenv import load_dotenv
from openai import OpenAI
# Load environment variables from .env file
load_dotenv()
# Initialize the OpenAI client (reads OPENAI_API_KEY automatically)
client = OpenAI()
print("Connection successful!")
print(f"Client ready: {client is not None}")
What does this code do?
- load_dotenv() reads your .env file and loads OPENAI_API_KEY into the environment.
- OpenAI() creates a client that automatically picks up your key from the environment. No need to pass it manually.
- The two print statements confirm the client was created without any authentication errors.
- Run the script by right-clicking inside the editor and selecting Run 'test_connection'.
- You should see this output in the Run tool window at the bottom:
Connection successful!
Client ready: True
Seeing an authentication error?
- Double-check that .env is in the same folder as test_connection.py (both should be in the weather-assistant root).
- Make sure there are no spaces around the = sign in your .env file.
- Confirm your OpenAI account has at least $5 in credits (required for Tier 1 API access).
Why am I getting an OpenAI authentication error?
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is what your .env file should look like:
OPENAI_API_KEY=your-api-key-here
Here is what your test_connection.py file should look like:
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
print("Connection successful!")
print(f"Client ready: {client is not None}")
Your environment is ready and authenticated. Next up, you will make your first real API call and see the model respond to your questions.
Make Your First API Call
Your environment is connected and ready to go. Now it's time to actually talk to the AI.
In this step, you'll send your first message to gpt-4o-mini and get a response back. You'll also explore how system prompts shape the model's personality and learn what metadata the API sends back with every response.
In this step, get ready to:
- Send a chat completion request and receive a text response from the model.
- Experiment with system prompts to control the model's personality.
- Inspect the response object to understand token usage and finish reasons.
Send a simple prompt to the model
The OpenAI Chat Completions API works like a conversation. You send a list of messages (with roles like "system" and "user") and the model sends back a completion.
- In PyCharm, create a new file called first_call.py inside your weather-assistant folder.
- Add the following setup code to initialize the OpenAI client:
import os
from dotenv import load_dotenv
from openai import OpenAI
# Load API key from .env file
load_dotenv()
# Create the OpenAI client (automatically uses OPENAI_API_KEY)
client = OpenAI()
What does this code do?
This is the same pattern you used in test_connection.py. load_dotenv() reads your .env file and loads the API key into the environment. The OpenAI() client automatically picks up the OPENAI_API_KEY variable from there.
- Now add the API call and response print below the client setup:
# Send a chat completion request to gpt-4o-mini
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of Poland?"},
],
)
# Print the model's response text
print(response.choices[0].message.content)
What does this code do?
- client.chat.completions.create() sends your messages to the model and waits for a response.
- The messages list contains two messages: a "system" message that sets the AI's personality, and a "user" message that asks the actual question.
- response.choices[0].message.content extracts the text the model generated.
- Save first_call.py.
- Run the script in your terminal by running:
python first_call.py
- You should see a text response from the model answering "What is the capital of Poland?" (something like "The capital of Poland is Warsaw.").
Seeing an authentication error?
- Make sure your .env file is in the same folder as first_call.py.
- Check that your API key is correctly pasted with no extra spaces or line breaks.
- Confirm you have at least $5 in credits loaded on your OpenAI account (required for Tier 1 API access).
Why is my OpenAI API call failing with an authentication error?
Experiment with system prompts
The system message is how you control the AI's behavior. Changing it completely transforms the tone and style of the response.
- In first_call.py, find this line:
{"role": "system", "content": "You are a helpful assistant."},
- Replace "You are a helpful assistant." with "You are a pirate who answers everything in pirate speak.", so the line looks like this:
{"role": "system", "content": "You are a pirate who answers everything in pirate speak."},
- Save the file and run it again:
python first_call.py
The model now answers the same question in pirate speak. Same question, completely different personality.
What is prompt engineering?
What you just did is a form of prompt engineering. The system message shapes how the model responds without changing the underlying model itself. In production AI systems, crafting the right system prompt is one of the most important skills.
- Change the system message back to "You are a helpful assistant." before moving on.
Understand the response object
The API sends back more than just text. The response object contains metadata about the model used, how many tokens it consumed, and why it stopped generating.
- In first_call.py, add these print statements below the existing print(response.choices[0].message.content) line:
print(f"\nModel: {response.model}")
print(f"Tokens used: {response.usage.total_tokens}")
print(f"Finish reason: {response.choices[0].finish_reason}")
What do these fields tell you?
- response.model confirms which model processed your request.
- response.usage.total_tokens shows how many tokens (input + output) the call consumed. This is what determines your cost.
- response.choices[0].finish_reason tells you why the model stopped generating. A value of stop means it finished naturally.
- Save first_call.py and run it again:
python first_call.py
- You should see the model's text answer followed by the model name, a token count, and a finish reason of stop.
Why does token count matter?
OpenAI charges per token. With gpt-4o-mini priced at $0.15 per million input tokens, a typical call in this project costs a fraction of a cent. Monitoring token usage helps you estimate costs as you build more complex applications.
Not seeing token usage or finish reason?
- Make sure the new print statements are outside and below the API call (not inside the parentheses of the create call).
- Check for typos in response.usage.total_tokens. Python is case-sensitive.
Help me debug my OpenAI response object print statements.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of Poland?"},
],
)
print(response.choices[0].message.content)
print(f"\nModel: {response.model}")
print(f"Tokens used: {response.usage.total_tokens}")
print(f"Finish reason: {response.choices[0].finish_reason}")
You've made your first API call and explored how the model responds. Next up, you'll build a weather tool that the AI can call on its own.
Define Your Weather Tool
In the last step, you proved that your code can talk to the OpenAI API and get a response. Now you are going to give the model a tool it can use on its own.
Function calling is the pattern where you describe a function to the AI model, and the model decides when to call it. Instead of generating a text response, the model says "I need to call this function with these arguments." Your code executes the function, feeds the result back, and the model uses it to answer the user.
In this step, get ready to:
- Create a mock weather function that returns data for a few cities.
- Define a tool schema that tells the model how to call your function.
- Test that the model requests a tool call instead of generating text.
Create the mock weather function
Before the AI can look up weather, you need a function that actually returns weather data. In a real app this would call a live API, but for learning purposes a simple dictionary lookup is perfect.
- In PyCharm, create a new file called weather_assistant.py inside your weather-assistant project folder.
- Add the imports and client setup at the top of the file by pasting this code:
import json
import os
from dotenv import load_dotenv
from openai import OpenAI
# Load environment variables and initialize the OpenAI client
load_dotenv()
client = OpenAI()
What does this code do?
This is the same setup pattern you used in first_call.py. It loads your API key from the .env file and creates an OpenAI client you can use to make API calls.
- Below the client setup, add the get_weather function by pasting this code:
def get_weather(location: str, units: str = "celsius") -> dict:
# Mock weather data for a few cities
weather_data = {
"Warsaw, Poland": {"temp": "22", "conditions": "Sunny"},
"London, UK": {"temp": "15", "conditions": "Cloudy and cool"},
"New York, US": {"temp": "28", "conditions": "Hot and humid"},
}
# Fallback for any city not in the dictionary
default = {"temp": "20", "conditions": "Clear skies"}
data = weather_data.get(location, default)
unit_symbol = "C" if units == "celsius" else "F"
return {
"location": location,
"temperature": f"{data['temp']} degrees {unit_symbol}",
"conditions": data["conditions"],
}
What does this function do?
- weather_data is a dictionary mapping city names to temperature and conditions. This acts as your fake weather database.
- default provides a fallback for any city not in the dictionary, so the function never crashes.
- The function returns a structured dictionary with location, temperature, and conditions keys. This is the data the AI model will use to answer the user.
- Save weather_assistant.py.
You will not see any output yet. The function exists but nothing calls it. The next chunk defines how the AI model discovers it.
Define the tool schema for OpenAI
The AI model cannot see your Python code. It needs a JSON Schema description of your function: its name, what it does, and what arguments it accepts. The model reads this schema and decides when to call the function.
- Below the get_weather function, add the tool definition by pasting this code:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a given location.",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and country, e.g. Warsaw, Poland",
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature units to return",
},
},
"required": ["location", "units"],
"additionalProperties": False,
},
},
}
]
What does this schema tell the model?
- "name": "get_weather" tells the model which function to request. This must match your Python function name exactly.
- "strict": True ensures the model's arguments always match your schema. This prevents runtime errors from malformed arguments.
- "enum": ["celsius", "fahrenheit"] constrains the units parameter to one of two valid values. The model will never invent a third option.
- "additionalProperties": False prevents the model from adding extra arguments that your function does not expect. This is required when strict mode is enabled.
- Save weather_assistant.py.
You still will not see any output. The next chunk sends this schema to the model and confirms it triggers a tool call.
Test that the model requests a tool call
Time to see function calling in action. You will send a weather question to the model along with your tools list. Instead of answering with text, the model should respond with a tool call containing the function name and arguments.
- Below the tools list, add the test code by pasting this at the bottom of your file:
# Test: send a request and check for tool_calls
messages = [
{"role": "system", "content": "You are a helpful weather assistant. Use the get_weather tool to answer weather questions."},
{"role": "user", "content": "What's the weather in Warsaw?"},
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
# Check if the model made a tool call instead of responding with text
assistant_message = response.choices[0].message
if assistant_message.tool_calls:
tool_call = assistant_message.tool_calls[0]
print(f"Function name: {tool_call.function.name}")
print(f"Arguments: {tool_call.function.arguments}")
else:
print("No tool call made.")
What does this code do?
- The tools=tools parameter passes your schema to the model. The model now knows it has a weather function available.
- assistant_message.tool_calls is a list of tool calls the model wants to make. If the model decides to use a tool, this list will contain the function name and the arguments it chose.
- The if/else checks whether the model chose to call a tool or just respond with plain text.
- Save weather_assistant.py.
- Run the file in your terminal by running:
python weather_assistant.py
- You should see output similar to:
Function name: get_weather
Arguments: {"location":"Warsaw, Poland","units":"celsius"}
The model did not answer the weather question. Instead, it requested a call to your get_weather function with the arguments it chose from your schema.
Seeing "No tool call made" instead?
- Make sure the tools=tools parameter is included in your client.chat.completions.create() call. Without it, the model does not know any tools exist.
- Check that your tools list is defined before the test code. Python reads top to bottom, so the variable must exist before you reference it.
- Verify the system message mentions using the tool. The system prompt nudges the model to prefer tool calls over guessing.
Why is my model not making a tool call?
Why is this so important?
This is the core pattern behind every AI agent. The model does not have internet access or live data. Instead, it tells YOUR code what to fetch. Your code executes the function, and the model uses the result to generate a final answer.
Right now the loop is incomplete. The model requested the tool call, but you have not actually executed get_weather() or sent the result back. That is what you will build in the next step.
✔️ Awesome, I've got everything!
Great. Double check you have saved weather_assistant.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete weather_assistant.py file so far. Compare it with yours line by line.
import json
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
def get_weather(location: str, units: str = "celsius") -> dict:
weather_data = {
"Warsaw, Poland": {"temp": "22", "conditions": "Sunny"},
"London, UK": {"temp": "15", "conditions": "Cloudy and cool"},
"New York, US": {"temp": "28", "conditions": "Hot and humid"},
}
default = {"temp": "20", "conditions": "Clear skies"}
data = weather_data.get(location, default)
unit_symbol = "C" if units == "celsius" else "F"
return {
"location": location,
"temperature": f"{data['temp']} degrees {unit_symbol}",
"conditions": data["conditions"],
}
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a given location.",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and country, e.g. Warsaw, Poland",
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature units to return",
},
},
"required": ["location", "units"],
"additionalProperties": False,
},
},
}
]
# Test: send a request and check for tool_calls
messages = [
{"role": "system", "content": "You are a helpful weather assistant. Use the get_weather tool to answer weather questions."},
{"role": "user", "content": "What's the weather in Warsaw?"},
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
assistant_message = response.choices[0].message
if assistant_message.tool_calls:
tool_call = assistant_message.tool_calls[0]
print(f"Function name: {tool_call.function.name}")
print(f"Arguments: {tool_call.function.arguments}")
else:
print("No tool call made.")
Your tool schema is working. The model knows how to ask for weather data. Next, you will close the loop by actually executing the function and sending the result back so the model can produce a final natural language answer.
Complete the Function Calling Loop
In the last step, you confirmed that the model knows when to request a tool call. It sees a weather question, and instead of guessing, it asks YOUR code for real data.
Now you need to close the loop. The model requested data, so you'll execute the function, send the result back, and let the model generate a final answer using that data. This is the core orchestration pattern behind every AI agent in production.
In this step, get ready to:
- Execute the tool call and send the result back to the model.
- Get a natural language response that incorporates your weather data.
- Wrap the entire flow in a reusable function you can test with any question.
Execute the tool call and send results back
The function calling loop has five steps: send a request with tools, receive a tool call, execute your code, send the result back, and receive the final response. Right now your script handles the first two steps. Time to build the rest.
- In weather_assistant.py, find the test code at the bottom of the file that starts with this comment:
# Test: send a request and check for tool_calls
- Delete everything from that comment down to the end of the file.
- Replace it with this ask_weather_assistant function that handles the complete loop:
def ask_weather_assistant(question: str) -> str:
messages = [
{
"role": "system",
"content": "You are a helpful weather assistant. Use the get_weather tool to answer weather questions. Always use the tool before answering.",
},
{"role": "user", "content": question},
]
# Step 1: Send the request with tools available
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
assistant_message = response.choices[0].message
# Step 2: Check if the model requested a tool call
if assistant_message.tool_calls:
messages.append(assistant_message)
# Step 3: Execute each tool call and send results back
for tool_call in assistant_message.tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
if function_name == "get_weather":
result = get_weather(**function_args)
else:
result = {"error": f"Unknown function: {function_name}"}
# Step 4: Append the tool result as a "tool" role message
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
}
)
# Step 5: Send the updated messages back for a final response
final_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
)
return final_response.choices[0].message.content
return None
What does this code do?
This function orchestrates the full conversation loop between your code and the AI model:
- messages.append(assistant_message) adds the model's tool call request to the conversation history so the model remembers what it asked for.
- json.loads(tool_call.function.arguments) parses the arguments string the model generated into a Python dictionary you can pass to your function.
- The "role": "tool" message sends your function's result back to the model, tagged with the tool_call_id so it knows which request this answers.
- The second client.chat.completions.create() call gives the model the weather data so it can write a natural language answer.
Why append the assistant message first?
The OpenAI API is stateless. It has no memory of previous calls. By appending BOTH the assistant's tool call message AND your tool result to the messages list, you're reconstructing the full conversation history for the second call.
Get the final response
The function is complete. Now you need an entry point to run it. The model will receive your weather data and generate a natural language response that incorporates it.
- At the very bottom of weather_assistant.py, add this entry point that calls your function and prints the result:
if __name__ == "__main__":
question = "What's the weather like in Warsaw, Poland?"
print(f"Question: {question}")
answer = ask_weather_assistant(question)
print(answer)
What does this code do?
if __name__ == "__main__": means this code only runs when you execute the file directly. It won't run if you import the file from another script later. This is a standard Python pattern for making code both runnable and importable.
- Save weather_assistant.py.
- Run the script in your terminal:
python weather_assistant.py
- You should see a natural language response that mentions Warsaw's weather being 22 degrees and sunny. The exact wording varies each run because the model generates it fresh.
Seeing an error or no output?
- If you see a JSON decode error, check that the json.loads() line matches exactly. The model's arguments come as a string that needs parsing.
- If the output is None, the model didn't make a tool call. Make sure your system message includes "Always use the tool before answering."
- If you see an authentication error, confirm your .env file is in the same folder as weather_assistant.py and contains a valid API key.
Help me debug my weather assistant function calling loop.
Wrap it in a reusable function
Your ask_weather_assistant(question) function encapsulates the entire flow. You can call it with any weather question and get back a natural language answer. Try it with a different city.
- In weather_assistant.py, find this line in the if __name__ block:
question = "What's the weather like in Warsaw, Poland?"
- Change the question to ask about London:
question = "What's the weather in London?"
- Save and run the script again:
python weather_assistant.py
- You should see a response mentioning London's weather being 15 degrees and cloudy. The model used the same function but with different arguments.
- Change the question back to the Warsaw version before moving on:
question = "What's the weather like in Warsaw, Poland?"
- Save weather_assistant.py.
Why is this pattern powerful?
The model decides WHEN to call your function and WHAT arguments to pass. You never hard-coded "if the user says London, call get_weather with London." The model figured that out from the tool schema and the user's question.
This is the same pattern behind ChatGPT plugins, Copilot, and enterprise AI agents. The model acts as an orchestrator that connects natural language to your code.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete weather_assistant.py at this point:
import json
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
def get_weather(location: str, units: str = "celsius") -> dict:
weather_data = {
"Warsaw, Poland": {"temp": "22", "conditions": "Sunny"},
"London, UK": {"temp": "15", "conditions": "Cloudy and cool"},
"New York, US": {"temp": "28", "conditions": "Hot and humid"},
}
default = {"temp": "20", "conditions": "Clear skies"}
data = weather_data.get(location, default)
unit_symbol = "C" if units == "celsius" else "F"
return {
"location": location,
"temperature": f"{data['temp']} degrees {unit_symbol}",
"conditions": data["conditions"],
}
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a given location.",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and country, e.g. Warsaw, Poland",
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature units to return",
},
},
"required": ["location", "units"],
"additionalProperties": False,
},
},
}
]
def ask_weather_assistant(question: str) -> str:
messages = [
{
"role": "system",
"content": "You are a helpful weather assistant. Use the get_weather tool to answer weather questions. Always use the tool before answering.",
},
{"role": "user", "content": question},
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
assistant_message = response.choices[0].message
if assistant_message.tool_calls:
messages.append(assistant_message)
for tool_call in assistant_message.tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
if function_name == "get_weather":
result = get_weather(**function_args)
else:
result = {"error": f"Unknown function: {function_name}"}
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
}
)
final_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
)
return final_response.choices[0].message.content
return None
if __name__ == "__main__":
question = "What's the weather like in Warsaw, Poland?"
print(f"Question: {question}")
answer = ask_weather_assistant(question)
print(answer)
Your weather assistant now handles the full loop: question in, tool call out, function executed, result back, natural language answer. Next up, you'll add structured outputs so the model returns typed data you can use programmatically in any application.
Add Structured Outputs with Pydantic
Your weather assistant works. It calls your tool, gets data, and returns a natural language string. But that string is hard to use programmatically. You cannot reliably pull the temperature out of a sentence like "It's 22 degrees and sunny in Warsaw."
In this step, you will add Structured Outputs to your assistant. Instead of returning free-form text, the model will return a typed Python object with named fields you can access directly.
In this step, get ready to:
- Define a Pydantic response model that describes the exact shape you want.
- Switch the second API call to use the SDK's parse method for structured responses.
- Access the typed data programmatically as individual Python attributes.
Define a Pydantic response model
Right now your ask_weather_assistant function returns a plain string. To get structured data, you need to define a Pydantic model that describes the exact fields you want the AI to fill in.
- In weather_assistant.py, find the import section at the top of the file. Add this new import on the line after from openai import OpenAI:
from pydantic import BaseModel
- Below the client = OpenAI() line, add the WeatherReport class by copying this code:
class WeatherReport(BaseModel):
location: str
temperature: str
conditions: str
recommendation: str
What does this code do?
A Pydantic BaseModel class defines a strict data contract. Each field has a name and a type.
When you pass this class to the OpenAI SDK, it automatically converts it into a JSON Schema and sends it to the API. The model is then forced to respond with exactly these four fields, no more and no less.
- Save weather_assistant.py.
Seeing an import error for pydantic?
The pydantic library is already installed as a dependency of the openai package. If you see an error, make sure your import reads from pydantic import BaseModel exactly.
help me fix my Pydantic import error in weather_assistant.py
Use the parse method for structured responses
The OpenAI SDK provides a special client.beta.chat.completions.parse() method that accepts a Pydantic model and automatically parses the AI's response into a typed Python object.
- In weather_assistant.py, find the function signature for ask_weather_assistant. It currently looks like this:
def ask_weather_assistant(question: str) -> str:
- Change the return type from str to WeatherReport, so the line now looks like this:
def ask_weather_assistant(question: str) -> WeatherReport:
- Now find the final_response block inside the if assistant_message.tool_calls: section. It currently looks like this:
final_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
)
return final_response.choices[0].message.content
- Replace those lines with the following code that uses the parse method and the response_format parameter:
final_response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=messages,
response_format=WeatherReport,
)
return final_response.choices[0].message.parsed
What changed here?
You made two key swaps. First, client.chat.completions.create() became client.beta.chat.completions.parse(). The parse method is a wrapper that automatically converts your Pydantic model into a JSON Schema, sends it to the API, and parses the response back into your model.
Second, you added response_format=WeatherReport to tell the API exactly which structure to produce. The return value changes from .message.content (a string) to .message.parsed (a typed WeatherReport object).
- Save weather_assistant.py.
Getting a ValueError about strict tools?
The parse() method requires all function tools in the request to have "strict": True set. Your tools list already has this from the previous step. If you see this error, double-check that your tool definition includes "strict": True and "additionalProperties": False.
help me fix the strict tools ValueError in my weather assistant
Use the typed data programmatically
The final step is to update the main block so it prints each field individually instead of printing a single string.
- In weather_assistant.py, find the if __name__ == "__main__": block at the bottom of the file. It currently looks like this:
if __name__ == "__main__":
question = "What's the weather like in Warsaw, Poland?"
print(f"Question: {question}")
answer = ask_weather_assistant(question)
print(answer)
- Replace the entire if __name__ == "__main__": block with this updated version:
if __name__ == "__main__":
question = "What's the weather like in Warsaw, Poland?"
print(f"Question: {question}")
print("Asking weather assistant...\n")
report = ask_weather_assistant(question)
if report:
print(f"Location: {report.location}")
print(f"Temperature: {report.temperature}")
print(f"Conditions: {report.conditions}")
print(f"Recommendation: {report.recommendation}")
else:
print("Could not get weather information.")
What does this code do?
Instead of printing a blob of text, you now access each field directly: report.location, report.temperature, report.conditions, and report.recommendation.
This is the real power of structured outputs. You have typed, validated data you can feed into a UI, store in a database, or pass to another API. No string parsing required.
- Save weather_assistant.py.
- Run the script by executing this command in your terminal:
python weather_assistant.py
You should see output with each field printed on its own line, something like:
Question: What's the weather like in Warsaw, Poland?
Asking weather assistant...
Location: Warsaw, Poland
Temperature: 22 degrees C
Conditions: Sunny
Recommendation: Great weather for a walk outside!
Seeing None printed instead of a report?
This means assistant_message.tool_calls was empty, so the model responded with text instead of calling the tool. Make sure your system message still says "Always use the tool before answering." Also confirm the tools list is passed to the first client.chat.completions.create() call.
help me debug why my weather assistant is returning None
✔️ Awesome, I've got everything!
Great. Double check that you have saved weather_assistant.py and that your script prints individual fields when you run it.
ⓧ I'd like to double check the full code
Here is the complete weather_assistant.py file at this point:
import json
import os
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel
load_dotenv()
client = OpenAI()
class WeatherReport(BaseModel):
location: str
temperature: str
conditions: str
recommendation: str
def get_weather(location: str, units: str = "celsius") -> dict:
weather_data = {
"Warsaw, Poland": {"temp": "22", "conditions": "Sunny"},
"London, UK": {"temp": "15", "conditions": "Cloudy and cool"},
"New York, US": {"temp": "28", "conditions": "Hot and humid"},
}
default = {"temp": "20", "conditions": "Clear skies"}
data = weather_data.get(location, default)
unit_symbol = "C" if units == "celsius" else "F"
return {
"location": location,
"temperature": f"{data['temp']} degrees {unit_symbol}",
"conditions": data["conditions"],
}
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a given location.",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and country, e.g. Warsaw, Poland",
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature units to return",
},
},
"required": ["location", "units"],
"additionalProperties": False,
},
},
}
]
def ask_weather_assistant(question: str) -> WeatherReport:
messages = [
{
"role": "system",
"content": "You are a helpful weather assistant. Use the get_weather tool to answer weather questions. Always use the tool before answering.",
},
{"role": "user", "content": question},
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
assistant_message = response.choices[0].message
if assistant_message.tool_calls:
messages.append(assistant_message)
for tool_call in assistant_message.tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
if function_name == "get_weather":
result = get_weather(**function_args)
else:
result = {"error": f"Unknown function: {function_name}"}
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
}
)
final_response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=messages,
response_format=WeatherReport,
)
return final_response.choices[0].message.parsed
return None
if __name__ == "__main__":
question = "What's the weather like in Warsaw, Poland?"
print(f"Question: {question}")
print("Asking weather assistant...\n")
report = ask_weather_assistant(question)
if report:
print(f"Location: {report.location}")
print(f"Temperature: {report.temperature}")
print(f"Conditions: {report.conditions}")
print(f"Recommendation: {report.recommendation}")
else:
print("Could not get weather information.")
Secret mission
Multi-Tool AI Agent
Your weather assistant currently has one tool. Real AI agents have many. In this secret mission, you'll add a second tool and watch the model intelligently choose which one to call based on the user's question.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs from leaving the files on your machine.
Resources you used:
- The weather-assistant project folder (containing .env, first_call.py, test_connection.py, and weather_assistant.py).
- Python packages: openai and python-dotenv.
- Your OpenAI API key (stored in your .env file and on platform.openai.com).
Keep everything running
No action needed. Choose this if you're still actively building or want to extend the project further.
- Your project files stay on your Desktop.
- Your OpenAI API key remains active. You only pay when you run scripts that make API calls.
- The openai and python-dotenv packages stay installed in your Python environment.
Pause - I'll come back to this later
Disable your API key to prevent accidental usage while keeping your code intact.
- Go to https://platform.openai.com/api-keys in your browser.
- Find the key you created for this project and click the trash icon to revoke it.
- When you come back, create a new key and update your .env file with the new value.
Delete - I don't want to use this again
Remove all project resources and revoke your API key.
- Revoke your OpenAI API key by going to https://platform.openai.com/api-keys, finding the key you created, and clicking the trash icon to delete it.
- Delete the project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf weather-assistant
On Windows, run:
cd ~\Desktop
rmdir /s /q weather-assistant
- Uninstall the Python packages by running:
pip uninstall openai python-dotenv -y
Nice Work!
Nice Work!
You just built an AI weather assistant that autonomously calls your custom functions and returns structured, typed data. This is the exact pattern used in production AI systems like ChatGPT plugins, Copilot, and enterprise AI agents.
You've learned how to:
- Connected Python to the OpenAI API and made your first chat completion call with system prompts and token tracking.
- Defined a custom tool with a JSON Schema and implemented the complete function calling loop where the model requests data from your code.
- Added Structured Outputs with Pydantic to get typed, validated responses you can use programmatically in any application.
- Secret Mission: Built a multi-tool agent that intelligently chooses between different functions based on the user's question.
Ready to quiz yourself?