04 / writing

Revisiting OpenAI Function Calling with Strict JSON Output

· 4 min read

Dark blue diagram titled “Function Calling and Traditional Programming”, with an AI cloud icon, an OpenAI logo linking a code panel to a get_current_time() panel, and a clock

If you have been working with AI for a while, function calling is something that should come quite natural to you by now. If not, this tutorial will explain how to make it work simply but effectively using OpenAI’s Responses API. As a reminder, function calling is the feature that bridges the gap between AI language models and your code’s capabilities. In other words, how you can combine AI development with traditional programming. In this post, we’ll explore how OpenAI’s function calling works and build a simple timezone converter to demonstrate its potential.

What is Function Calling?

Function calling allows large language models (LLMs) like GPT-4o to:

  1. Recognise when a user’s request requires a specific function
  2. Generate a properly formatted JSON object with the necessary parameters
  3. Call that function and use its returned result in the conversation

Building a Timezone Converter Assistant

Let’s break down a practical example — a timezone converter that tells users the current time anywhere in the world. This demonstrates the core concepts while being immediately useful.

The Code Explained

First, let’s define our function, the function that will be called by the LLM:

def get_current_time(timezone: str) -> str:
    # Get the timezone object
    timezone = pytz.timezone(timezone)
    
    # Get current time in UTC
    utc_time = datetime.now(pytz.UTC)
    
    # Convert to desired timezone
    local_time = utc_time.astimezone(timezone)
    
    # Format the time
    formatted_time = local_time.strftime("%I:%M:%S %p %Z")
    
    return formatted_time

Next, we need to describe this function to the OpenAI model:

tools = [
    {
        "type": "function",
        "name": "get_current_time",
        "description": "Get the current time in the specified timezone",
        "parameters": {
            "type": "object",
            "properties": {
                "timezone": {
                    "type": "string",
                    "description": "The timezone to get the current time for"
                }
            },
            "required": ["timezone"],
            "additionalProperties": False
        },
        "strict": True
    }
]

This JSON schema tells the model:

The Flow of Execution

When a user asks something like What time is it in Seville?, here’s what happens:

  1. The user’s question is sent to OpenAI’s API along with our function definition
  2. The model recognises this requires the get_current_time function and generates a function call
  3. Our code extracts the function name and arguments from the response
  4. We execute the real Python function and get the result
  5. We send the function result back to the API for a final response
  6. The model crafts a natural language response incorporating the function’s output

Handling the Function Call

if response.output[0].type == "function_call":
    tool_call = response.output[0]
    args = json.loads(tool_call.arguments)
    name = tool_call.name
    # Call the function by name
    result = globals()[name](**args)
    # Send the result back to the API
    input_messages.append(tool_call)
    input_messages.append({
        "type": "function_call_output",
        "call_id": tool_call.call_id,
        "output": str(result)
    })
    
    response = client.responses.create(
        model="gpt-4o",
        input=input_messages,
        tools=tools,
    )

This pattern is powerful — the model decides when to call functions based on the user’s intent, not through explicit programming logic.

I’ve seen some people doing if‘s against the tool_call‘s name, so they know which function to call. In my opinion, this is not the best way to handle it, as it is not easily extensible. Every time you add a new function you have to remember to change this code so the function can be recognised and called.

Best Practices

  1. Clear descriptions: Make function and parameter descriptions clear and specific. Avoid defining parameters that can lead to contradictions (for example, parameters that should be called with mutual exclusivity)
  2. Appropriate tooling: Only expose functions that make sense for your use case. Don’t expose too many (soft rule, around 20)
  3. Error handling: Gracefully handle cases where functions fail or return unexpected results

Getting Started

To try this example yourself:

  1. Create a virtual environment and activate it: python -m venv .venv && source .venv/bin/activate
  2. Install the required packages: openai, python-dotenv, and pytz
  3. Set up your OpenAI API key in a .env file
  4. Run the code and ask about the time in different cities: python openai_function_calling.py

In future articles I will explore how to do this using other APIs and AI frameworks, so stay tuned!

Full Code

from openai import OpenAI
import dotenv
import json
import pytz
from datetime import datetime

dotenv.load_dotenv()

model = "gpt-4o"

def get_current_time(timezone: str) -> str: 
    timezone = pytz.timezone(timezone)
    utc_time = datetime.now(pytz.UTC)
    local_time = utc_time.astimezone(timezone)
    formatted_time = local_time.strftime("%I:%M:%S %p %Z")
    
    return formatted_time

def main():
    client = OpenAI()

    tools = [
        {
            "type": "function",
            "name": "get_current_time",
            "description": "Get the current time in the specified timezone",
            "parameters": {
                "type": "object",
                "properties": {
                    "timezone": {
                        "type": "string",
                        "description": "The timezone to get the current time for"
                    }
                },
                "required": ["timezone"],
                "additionalProperties": False
            },
            "strict": True
        }
    ]

    question = input("Ask me a question: ")
    input_messages = [
        {
            "role": "user",
            "content": question
        }
    ]

    response = client.responses.create(
        model=model,
        input=input_messages,
        tools=tools,
    )

    if response.output[0].type == "function_call":
        tool_call = response.output[0]
        args = json.loads(tool_call.arguments)
        name = tool_call.name
        # Call the function called 'name'
        result = globals()[name](**args)
        input_messages.append(tool_call)
        input_messages.append({
            "type": "function_call_output",
            "call_id": tool_call.call_id,
            "output": str(result)
        })
        
        response = client.responses.create(
            model=model,
            input=input_messages,
            tools=tools,
        )
    
    print(response.output_text)

if __name__ == "__main__":
    main()

Originally published on Medium ↗ · All writing