DEV Community

Manas Joshi
Manas Joshi

Posted on

Integrate Custom Tools with OpenAI Function Calling

Large Language Models (LLMs) are incredibly powerful for generating text, summarizing information, and answering questions. However, their knowledge is typically limited to their training data. To perform real-world actions like checking the weather, sending emails, or querying a database, LLMs need to interact with external tools. This is where "function calling" or "tool use" becomes essential. It’s the mechanism that allows an LLM to not just generate text, but to intelligently decide when and how to invoke external functions, transforming it from a text generator into a capable agent.

In this guide, we'll dive into OpenAI's Function Calling feature, a robust way to connect your LLM applications with custom tools and APIs. We'll explore the core concepts, walk through practical Python code examples, and discuss common pitfalls to help you build more dynamic and powerful AI-driven applications.

Understanding OpenAI Function Calling

OpenAI Function Calling empowers models like gpt-4o to detect when a user's prompt might require an external function call, generate the necessary arguments for that call, and present this structured information back to your application. Your application then executes the function and feeds the result back to the LLM, allowing it to complete the user's request with up-to-date, real-world data or perform specific actions.

This isn't the LLM executing code directly. Instead, it acts as a smart orchestrator:

  1. You describe your tools: You provide the LLM with a JSON Schema definition of functions it can "call" (their names, descriptions, and expected parameters).
  2. User prompt: A user asks a question or makes a request.
  3. LLM decides: The LLM analyzes the prompt, compares it against the available tool descriptions, and decides if a tool is needed.
  4. LLM generates tool call: If a tool is needed, the LLM generates a structured JSON object containing the tool's name and the arguments it inferred from the user's prompt.
  5. Your application executes: Your code receives this JSON, calls your actual Python function (or external API) with the provided arguments.
  6. Tool output is returned: The result of your function call is then sent back to the LLM.
  7. LLM synthesizes response: The LLM uses this real-world data to formulate a natural language response to the user.

Let's illustrate this with a common example: fetching current weather information.

Step 1: Describing Your Tools to the LLM

The first crucial step is to define the capabilities of your external tools in a way the LLM can understand. OpenAI uses a JSON Schema-like structure for this. Each tool needs a descriptive name, a clear description (this is key for the LLM's understanding), and a parameters object detailing the inputs it expects.

Consider a simple get_current_weather function that takes a location and an optional unit.

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_current_weather",
            "description": "Get the current weather in a given location",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "The city and state, e.g. San Francisco, CA",
                    },
                    "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
                },
                "required": ["location"],
            },
        },
    }
]
Enter fullscreen mode Exit fullscreen mode

This Python list defines a single tool. The type is specified as "function", indicating it's a callable operation. Within the function object, we provide a name (get_current_weather) that your actual code will use, and a description that the LLM reads to understand the tool's purpose. The parameters field, written in JSON Schema format, details the expected inputs: location as a required string and unit as an optional string that must be either "celsius" or "fahrenheit".

Step 2: Triggering a Function Call from the LLM

Now that our tool is defined, we can pass it to the OpenAI API when making a chat completion request. The tool_choice parameter gives you control over whether the model should use a tool, always use a specific tool, or never use one. Setting it to "auto" (the default) lets the LLM decide.

from openai import OpenAI
import json # Used for parsing arguments later

client = OpenAI()

messages = [{"role": "user", "content": "What's the weather like in Boston?"}]

response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=tools, # Our defined tools
    tool_choice="auto", # Let the model decide if a tool is needed
)
response_message = response.choices[0].message

# Check if the model decided to call a tool
if response_message.tool_calls:
    tool_call = response_message.tool_calls[0] # Assuming one tool call for simplicity
    print(f"LLM wants to call a tool: {tool_call.function.name}")
    print(f"Arguments: {tool_call.function.arguments}")
else:
    print("LLM responded directly:", response_message.content)
Enter fullscreen mode Exit fullscreen mode

In this snippet, we initialize the OpenAI client and prepare a list of messages containing the user's query. The core of this step is the call to client.chat.completions.create. We pass our messages, specify a model (gpt-4o is good for function calling), and crucially, include our tools definition. tool_choice="auto" instructs the model to automatically determine if a function call is appropriate. The response_message then holds the model's decision; if response_message.tool_calls is populated, it means the LLM has generated a call to one of our defined tools, providing the function name and its parsed arguments as a JSON string.

Step 3: Executing the Tool and Sending Results Back

Receiving a tool_calls object means your application now needs to execute the identified function. This involves parsing the arguments provided by the LLM, calling your actual local function, and then sending the result back to the LLM to complete the conversation.

# Mock tool function (in a real application, this would query a weather API, database, etc.)
def get_current_weather(location, unit="fahrenheit"):
    # Simplified logic for demonstration
    if "boston" in location.lower():
        return json.dumps({"location": location, "temperature": "65", "unit": unit, "forecast": "partly cloudy"})
    elif "new york" in location.lower():
        return json.dumps({"location": location, "temperature": "72", "unit": unit, "forecast": "sunny"})
    return json.dumps({"location": location, "temperature": "unknown", "unit": unit, "forecast": "unavailable"})

# --- Continuation from Step 2, assuming response_message.tool_calls exists ---
if response_message.tool_calls:
    tool_call = response_message.tool_calls[0]
    function_name = tool_call.function.name
    function_args = json.loads(tool_call.function.arguments) # Parse the JSON string into a Python dict

    # Execute the function based on its name
    if function_name == "get_current_weather":
        function_response = get_current_weather(**function_args) # Unpack arguments with **

        # Append both the LLM's tool call and the tool's output to the messages history
        messages.append(response_message) # The model's request to call the tool
        messages.append(
            {
                "tool_call_id": tool_call.id,
                "role": "tool",
                "name": function_name,
                "content": function_response, # The actual result from our function
            }
        )

        # Send the updated messages back to the LLM to get a final, human-readable response
        second_response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
        )
        print("Final LLM response:", second_response.choices[0].message.content)
    else:
        print(f"Error: Unknown function requested: {function_name}")
else:
    # This case would be handled by the direct response logic in Step 2
    pass
Enter fullscreen mode Exit fullscreen mode

This expanded block handles the actual execution. We define a placeholder get_current_weather function that simulates an API call. After receiving the LLM's tool_call, we parse its arguments (which are a JSON string) into a Python dictionary using json.loads(). We then dynamically call our get_current_weather function, unpacking the arguments using **function_args. Crucially, we append two new messages to our messages history: first, the response_message from the LLM (which contained the tool_calls), and second, a new message with role="tool". This tool message includes the tool_call_id (linking it back to the original call), the function name, and the content which is the actual output from our get_current_weather function. Finally, we make a second API call to client.chat.completions.create with this updated message history, allowing the LLM to synthesize a natural language response based on the tool's output.

Common Mistakes and Gotchas

Working with Function Calling, while powerful, can introduce specific challenges:

  • Incorrect Schema Definition: The description of your tool and its parameters are vital. If they are vague or misleading, the LLM might struggle to correctly identify when to call your tool or how to map user input to its arguments. Ensure required parameters are correctly marked.
  • Forgetting to Send Tool Output: A common oversight is to call the tool but not feed its results back into the LLM's messages history. Without the role="tool" message, the LLM won't know the outcome of its requested action and cannot complete the user's request contextually.
  • Infinite Loops: In more complex scenarios, an LLM might continuously request the same tool if the tool's output doesn't provide enough information or resolve the initial query. Ensure your tool outputs are comprehensive, and consider adding retry limits or human intervention points.
  • Security Implications: Since your application executes the functions, be extremely cautious about what functions you expose and how you handle their arguments. Malicious prompts could potentially lead to unexpected function calls or data exposure if not properly validated. Always sanitize and validate inputs before executing actions.
  • Over-reliance on tool_choice="auto": While convenient, auto might not always pick the desired tool or might pick one when a direct response is better. For critical flows, consider explicit tool_choice values, e.g., {"type": "function", "function": {"name": "my_specific_tool"}}, or none to force a direct textual response.

Key Takeaways

OpenAI Function Calling fundamentally shifts how we interact with LLMs, moving beyond simple text generation to building truly intelligent, agentic systems. By providing a clear contract for your external tools through JSON Schema, you enable LLMs to:

  • Perform real-world actions: Interact with APIs, databases, or local services.
  • Access up-to-date information: Overcome knowledge cutoffs by fetching current data.
  • Improve user experience: Provide more accurate and actionable responses.

This pattern is a cornerstone for building sophisticated AI assistants, task automation agents, and advanced chatbots that can understand user intent and execute complex workflows.

Conclusion: Build Smarter, Not Harder

Integrating custom tools with LLMs via function calling unlocks a new dimension of possibilities for developers. It's a critical pattern for extending the capabilities of models beyond their inherent knowledge, turning them into versatile problem-solvers. The ability to dynamically connect LLMs to your existing backend services means you can create applications that are not just conversational, but genuinely functional and integrated.

Start experimenting with OpenAI Function Calling in your projects today. Define a simple tool, feed it to the model, and observe how your LLM begins to intelligently orchestrate actions. The future of AI applications lies in these powerful integrations.

Top comments (0)